Initial commit: PXEForge Phases 1-4
Container-native PXE boot server in Rust, designed as a clean-room alternative to iVentoy that never touches the client OS trust store. This is the first commit of the project; it lands the full output of Phases 1, 2, 3, and 4 in one shot. ## Phase 1 — protocol stack - 8-crate workspace (core, dhcp-proxy, tftp, http-api, iso-store, ipxe-assets, webui, pxeforge bin). - DHCP proxy (RFC 4578): replies with boot info only, never leases — sidesteps CAP_NET_RAW. Architecture-aware bootfile selection from option 93 (BIOS, IA32, x64-UEFI alias 0x0007/0x0009, ARM64). - TFTP server with full OACK negotiation: blksize, tsize, windowsize. Without it a 1 MiB iPXE binary takes 2000 packets and unusably long. - Two-stage iPXE chain: firmware PXE -> TFTP iPXE binary -> iPXE re-DHCPs with user-class iPXE -> HTTP /boot.ipxe -> kernel+initrd. - HTTP server (axum) with byte-Range ISO streaming and an in-place ISO9660 lookup so kernel/initrd are served from inside the ISO without ever extracting it to disk. - Linux ISOs boot via kernel+initrd extraction (memdisk/sanboot fail for >1-2 GiB modern distros). Distro-family detection drives the cmdline (Debian/Ubuntu, RHEL/Fedora, openSUSE, Arch, Alpine). ## Phase 2 — UX + Windows - Hierarchical PXE menu (Default / Installers / Tools / Gated Deployment) generated from settings — no hand-written .ipxe paths surface in the UI. Number-key + letter hotkeys, BIOS+UEFI variants for some RHEL ISOs. - Gated Deployment "horse-race" queue: clients join, operator picks one ISO, every gate launches simultaneously via tokio::sync::Notify. - Bootimus-pattern Windows: WimPatcher injects a CRLF startnet.cmd into boot.wim so vanilla WinPE net-uses an SMB share and runs setup.exe. All Microsoft-signed; no test certs, no testsigning, no httpdisk.sys. SmbManager supervises smbd start/stop/SIGHUP. - Netbox-style dark UI, fully offline (no CDN, no external fonts). ## Phase 3 — MVP hardening - TFTP retransmit rewrite with explicit window tracking — UEFI SNP clients no longer hang on files that end mid-window. 4 new tests. - DHCP broadcast-flag honored per RFC 2131 §4.1. - Multi-arch container (linux/amd64 + linux/arm64). Entrypoint chowns bind-mounts as root then drops to uid 10001 via gosu. - /healthz + /readyz split from /api/status — readyz fails if no iPXE binaries are bundled. - pxeforge seed --from <path> CLI: same pipeline as web upload (slug, sha256, introspection, boot-entry). - All timestamps RFC 3339 (browser Date couldn't parse the 9-tuple). - Gate poll retains assignment until operator releases — clients that retry on transient network errors reuse the assignment instead of falling back to the menu. - Custom OpenShift SCC: hostNetwork + NET_BIND_SERVICE only, no NET_RAW. ## Phase 4 — UI restructure + remote storage - Web UI rebuilt around six tabs inspired by the iVentoy layout: Dashboard / Network / Forge Gate / Storage / Terminal / About. Old "Monitoring/Content/Configuration" sidebar groups are gone. - NFS share manager (crates/iso-store/src/nfs.rs): mount NFSv3 or NFSv4.1 shares as ISO sources instead of uploading every file into the PVC. New IsoSource enum on IsoMeta lets the store resolve Local vs NFS lazily. Persisted to <work_dir>/nfs.json; failed mounts surface in the UI rather than blocking startup. - Dockerfile gains nfs-common + iproute2; mounting NFS in-container also requires CAP_SYS_ADMIN. Documented in docs/architecture.md. - LogBus + tracing layer in core: 500-line ring buffer + broadcast channel feed an SSE endpoint at /api/log/stream. - Operator terminal at /api/terminal: whitelisted commands (status, isos, clients, gate, nfs, smb, log) — deliberately not a shell. Output mirrored onto the LogBus so the live tail and the terminal pane share one timeline. - Network tab: read-only nic_name / subnet_mask / gateway probed from `ip` at startup; only DNS server is editable. Editing IP/mask on a hot UI would silently break PXE for every client mid-boot. - Bootimus parity (releases v0.1.55 -> v0.1.62): amber row tint on un-bootable ISOs with inline reasons, dashboard "won't boot" panel. ## Tests 56 tests passing across the workspace: - 16 core (LogBus, gate, settings, arch, client) - 1 dhcp-proxy (raw option-93 extraction) - 8 http-api unit (range parsing, terminal split/format) - 13 http-api integration (gated deployment, range, settings, NFS, terminal, log SSE, network endpoint, ui assets, no-external-urls) - 12 iso-store (introspect, slugify, smb, windows wim, NFS options) - 6 tftp (RRQ parsing, plan_window edges) cargo build --workspace and cargo clippy --workspace --all-targets both finish clean (warnings only, no errors).
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "pxeforge-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Shared types, config, and arch detection for PXEForge"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
time.workspace = true
|
||||
uuid.workspace = true
|
||||
parking_lot.workspace = true
|
||||
tokio = { workspace = true, features = ["sync", "rt", "macros", "time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.12"
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Client architecture detection from DHCP options.
|
||||
//!
|
||||
//! Primary source is DHCP option 93 (Client System Architecture, RFC 4578/5970).
|
||||
//! Some firmwares report `0x0009` ("EFI BC") instead of `0x0007` — aliased here.
|
||||
//! Secondary: option 60 vendor-class with `HTTPClient` signals native UEFI HTTP
|
||||
//! boot, in which case we can skip TFTP and return an http:// URL directly.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ClientArch {
|
||||
/// Legacy x86 BIOS PXE (option 93 = 0x0000).
|
||||
LegacyX86,
|
||||
/// IA32 UEFI (option 93 = 0x0006).
|
||||
Ia32Uefi,
|
||||
/// x86_64 UEFI (option 93 = 0x0007 or 0x0009).
|
||||
X64Uefi,
|
||||
/// ARM32 UEFI (option 93 = 0x000A).
|
||||
Arm32Uefi,
|
||||
/// ARM64 UEFI (option 93 = 0x000B).
|
||||
Arm64Uefi,
|
||||
/// Unknown / unsupported architecture; caller should log and skip.
|
||||
Unknown(u16),
|
||||
}
|
||||
|
||||
impl ClientArch {
|
||||
#[must_use]
|
||||
pub fn from_option_93(value: u16) -> Self {
|
||||
match value {
|
||||
0x0000 => Self::LegacyX86,
|
||||
0x0006 => Self::Ia32Uefi,
|
||||
0x0007 | 0x0009 => Self::X64Uefi,
|
||||
0x000A => Self::Arm32Uefi,
|
||||
0x000B => Self::Arm64Uefi,
|
||||
other => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Default iPXE binary filename to return via TFTP for this architecture.
|
||||
/// Uses `snponly` variants which reuse the firmware's UNDI/SNP network
|
||||
/// stack — smaller binaries and broader hardware compatibility than the
|
||||
/// all-drivers-included `ipxe.efi`.
|
||||
#[must_use]
|
||||
pub fn ipxe_bootfile(self) -> Option<&'static str> {
|
||||
Some(match self {
|
||||
Self::LegacyX86 => "undionly.kpxe",
|
||||
Self::Ia32Uefi => "snponly-i386.efi",
|
||||
Self::X64Uefi => "snponly.efi",
|
||||
// ARM32 UEFI: upstream boot.ipxe.org does not publish a prebuilt
|
||||
// snponly variant for this arch. We return None so the DHCP
|
||||
// proxy declines rather than advertising a file we can't serve.
|
||||
Self::Arm32Uefi => return None,
|
||||
Self::Arm64Uefi => "snponly-arm64.efi",
|
||||
Self::Unknown(_) => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::LegacyX86 => "bios",
|
||||
Self::Ia32Uefi => "uefi-ia32",
|
||||
Self::X64Uefi => "uefi-x64",
|
||||
Self::Arm32Uefi => "uefi-arm32",
|
||||
Self::Arm64Uefi => "uefi-arm64",
|
||||
Self::Unknown(_) => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which firmware class issued the DHCP request. Used to decide the reply
|
||||
/// path — PXEClient gets TFTP/iPXE chainload, HTTPClient gets an HTTP URL,
|
||||
/// iPXE itself gets the boot script URL.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FirmwareClass {
|
||||
/// Firmware PXE ROM (option 60 = "PXEClient").
|
||||
PxeClient,
|
||||
/// UEFI HTTP boot (option 60 = "HTTPClient").
|
||||
HttpClient,
|
||||
/// iPXE (option 77 user-class = "iPXE").
|
||||
IpxeUserClass,
|
||||
/// No recognised signature — likely not a PXE client at all.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl FirmwareClass {
|
||||
/// Classify a DHCP request. `vendor_class_60` is option 60 (vendor class
|
||||
/// identifier); `user_class_77` is option 77 (user class). Check user-class
|
||||
/// first because iPXE-that-we-chainloaded will set option 60 to PXEClient
|
||||
/// *and* option 77 to iPXE, and the iPXE classification wins.
|
||||
#[must_use]
|
||||
pub fn classify(vendor_class_60: Option<&[u8]>, user_class_77: Option<&[u8]>) -> Self {
|
||||
if let Some(uc) = user_class_77 {
|
||||
if uc.windows(b"iPXE".len()).any(|w| w == b"iPXE") {
|
||||
return Self::IpxeUserClass;
|
||||
}
|
||||
}
|
||||
match vendor_class_60 {
|
||||
Some(v) if v.starts_with(b"PXEClient") => Self::PxeClient,
|
||||
Some(v) if v.starts_with(b"HTTPClient") => Self::HttpClient,
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn arch_aliases_0x0009_to_x64() {
|
||||
assert_eq!(ClientArch::from_option_93(0x0007), ClientArch::X64Uefi);
|
||||
assert_eq!(ClientArch::from_option_93(0x0009), ClientArch::X64Uefi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_and_arm() {
|
||||
assert_eq!(ClientArch::from_option_93(0x0000), ClientArch::LegacyX86);
|
||||
assert_eq!(ClientArch::from_option_93(0x000B), ClientArch::Arm64Uefi);
|
||||
assert!(matches!(
|
||||
ClientArch::from_option_93(0x1234),
|
||||
ClientArch::Unknown(0x1234)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootfile_names_stable() {
|
||||
assert_eq!(ClientArch::LegacyX86.ipxe_bootfile(), Some("undionly.kpxe"));
|
||||
assert_eq!(ClientArch::X64Uefi.ipxe_bootfile(), Some("snponly.efi"));
|
||||
assert_eq!(ClientArch::Arm64Uefi.ipxe_bootfile(), Some("snponly-arm64.efi"));
|
||||
assert_eq!(ClientArch::Unknown(0xFFFF).ipxe_bootfile(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn firmware_class_detects_ipxe_over_pxeclient() {
|
||||
let c = FirmwareClass::classify(Some(b"PXEClient:Arch:00007"), Some(b"iPXE"));
|
||||
assert_eq!(c, FirmwareClass::IpxeUserClass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn firmware_class_http() {
|
||||
let c = FirmwareClass::classify(Some(b"HTTPClient:Arch:00016"), None);
|
||||
assert_eq!(c, FirmwareClass::HttpClient);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn firmware_class_plain_pxe() {
|
||||
let c = FirmwareClass::classify(Some(b"PXEClient"), None);
|
||||
assert_eq!(c, FirmwareClass::PxeClient);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! In-memory client state registry — the "who has contacted us" table the
|
||||
//! web UI displays. Not persisted: PXE sessions are ephemeral by nature.
|
||||
|
||||
use crate::arch::ClientArch;
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ClientEvent {
|
||||
DhcpDiscover,
|
||||
DhcpRequest,
|
||||
PxeBootServerRequest,
|
||||
TftpRead { file: String },
|
||||
HttpScriptFetch { target: String },
|
||||
HttpIsoAsset { file: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClientSnapshot {
|
||||
pub mac: String,
|
||||
pub last_ip: Option<IpAddr>,
|
||||
pub arch: Option<ClientArch>,
|
||||
pub hostname: Option<String>,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub first_seen: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub last_seen: OffsetDateTime,
|
||||
// Events are left with default serialization (9-tuple) — they're
|
||||
// diagnostic only and not consumed by the UI today.
|
||||
pub events: Vec<(OffsetDateTime, ClientEvent)>,
|
||||
/// The boot target (ISO id) last selected via the iPXE menu, if any.
|
||||
pub selected_target: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ClientRegistry {
|
||||
inner: RwLock<HashMap<String, ClientSnapshot>>,
|
||||
}
|
||||
|
||||
impl ClientRegistry {
|
||||
#[must_use]
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
pub fn record(
|
||||
&self,
|
||||
mac: &str,
|
||||
ip: Option<IpAddr>,
|
||||
arch: Option<ClientArch>,
|
||||
event: ClientEvent,
|
||||
) {
|
||||
let mut guard = self.inner.write();
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let entry = guard.entry(mac.to_string()).or_insert_with(|| ClientSnapshot {
|
||||
mac: mac.to_string(),
|
||||
last_ip: ip,
|
||||
arch,
|
||||
hostname: None,
|
||||
first_seen: now,
|
||||
last_seen: now,
|
||||
events: Vec::new(),
|
||||
selected_target: None,
|
||||
});
|
||||
entry.last_seen = now;
|
||||
if ip.is_some() { entry.last_ip = ip; }
|
||||
if arch.is_some() { entry.arch = arch; }
|
||||
entry.events.push((now, event));
|
||||
// Cap event history per client to keep memory bounded.
|
||||
const MAX_EVENTS: usize = 64;
|
||||
if entry.events.len() > MAX_EVENTS {
|
||||
let drop_n = entry.events.len() - MAX_EVENTS;
|
||||
entry.events.drain(..drop_n);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_selected_target(&self, mac: &str, target: Option<String>) {
|
||||
let mut guard = self.inner.write();
|
||||
if let Some(c) = guard.get_mut(mac) {
|
||||
c.selected_target = target;
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn list(&self) -> Vec<ClientSnapshot> {
|
||||
let guard = self.inner.read();
|
||||
let mut v: Vec<_> = guard.values().cloned().collect();
|
||||
v.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
|
||||
v
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(&self, mac: &str) -> Option<ClientSnapshot> {
|
||||
self.inner.read().get(mac).cloned()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub server: ServerConfig,
|
||||
pub network: NetworkConfig,
|
||||
pub paths: Paths,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ServerConfig {
|
||||
/// Address the web/API server binds to.
|
||||
pub http_bind: IpAddr,
|
||||
/// Port for the web/API + ISO/iPXE HTTP server (single port, multiplexed by path).
|
||||
pub http_port: u16,
|
||||
/// Address the TFTP server binds to.
|
||||
pub tftp_bind: IpAddr,
|
||||
/// Port for TFTP (RFC 1350 default is 69).
|
||||
pub tftp_port: u16,
|
||||
/// External hostname/IP clients should use to reach this server. If
|
||||
/// `None`, auto-detect from the interface that received the DHCP request
|
||||
/// (via IP_PKTINFO). This is what ends up in DHCP option 54 / siaddr,
|
||||
/// option 66 (TFTP server), and the base of generated iPXE URLs.
|
||||
pub public_ip: Option<Ipv4Addr>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct NetworkConfig {
|
||||
pub dhcp_mode: DhcpMode,
|
||||
/// Address the DHCP proxy/server binds to. For proxy mode, usually 0.0.0.0.
|
||||
pub dhcp_bind: IpAddr,
|
||||
/// UDP port for DHCP server-side receive. Standard is 67.
|
||||
pub dhcp_port: u16,
|
||||
/// UDP port for PXE Boot Server discovery. Standard is 4011.
|
||||
pub pxe_port: u16,
|
||||
/// Optional allowlist of client MAC prefixes (OUI). Empty = serve everyone.
|
||||
pub mac_allowlist: Vec<String>,
|
||||
/// Optional allowlist of subnets (CIDR). Empty = serve everyone.
|
||||
pub subnet_allowlist: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DhcpMode {
|
||||
/// Run as DHCP proxy (RFC 4578): reply with boot info only, don't lease
|
||||
/// IPs. Coexists with an existing DHCP server on the network. Default
|
||||
/// because it's the only mode that works in most real deployments without
|
||||
/// taking over address assignment.
|
||||
#[default]
|
||||
Proxy,
|
||||
/// Disabled — rely on an external DHCP server that has been manually
|
||||
/// configured with `next-server` / `filename`. PXEForge only serves TFTP
|
||||
/// + HTTP in this mode. Useful for home routers that can be pre-set.
|
||||
Disabled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Paths {
|
||||
/// Directory holding uploaded ISO files.
|
||||
pub iso_dir: PathBuf,
|
||||
/// Directory for extracted kernel/initrd and other per-ISO derived assets.
|
||||
pub work_dir: PathBuf,
|
||||
/// Directory containing bundled iPXE binaries (undionly.kpxe, snponly.efi, ...).
|
||||
pub ipxe_dir: PathBuf,
|
||||
/// Path to the wimboot binary for Windows ISOs (optional — feature-gated).
|
||||
pub wimboot_path: Option<PathBuf>,
|
||||
/// Directory under which Windows ISOs are extracted and served via SMB.
|
||||
/// Only used when `settings.windows_enabled = true`. Defaults to
|
||||
/// `/var/lib/pxeforge/smb` in the container image.
|
||||
pub smb_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
http_bind: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||
http_port: 80,
|
||||
tftp_bind: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||
tftp_port: 69,
|
||||
public_ip: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NetworkConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dhcp_mode: DhcpMode::Proxy,
|
||||
dhcp_bind: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||
dhcp_port: 67,
|
||||
pxe_port: 4011,
|
||||
mac_allowlist: Vec::new(),
|
||||
subnet_allowlist: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Paths {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
iso_dir: PathBuf::from("/var/lib/pxeforge/isos"),
|
||||
work_dir: PathBuf::from("/var/lib/pxeforge/work"),
|
||||
ipxe_dir: PathBuf::from("/usr/share/pxeforge/ipxe"),
|
||||
wimboot_path: None,
|
||||
smb_dir: PathBuf::from("/var/lib/pxeforge/smb"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
server: ServerConfig::default(),
|
||||
network: NetworkConfig::default(),
|
||||
paths: Paths::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_toml_file(path: &Path) -> crate::Result<Self> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
toml::from_str(&text).map_err(|e| crate::Error::Config(e.to_string()))
|
||||
}
|
||||
|
||||
/// Apply environment variable overrides. Env var names follow the pattern
|
||||
/// `PXEFORGE_<SECTION>_<FIELD>`, uppercase. Unknown vars are ignored.
|
||||
/// Call this after loading the TOML file so env takes precedence.
|
||||
pub fn apply_env(&mut self) {
|
||||
if let Ok(v) = std::env::var("PXEFORGE_HTTP_PORT") {
|
||||
if let Ok(p) = v.parse() { self.server.http_port = p; }
|
||||
}
|
||||
if let Ok(v) = std::env::var("PXEFORGE_TFTP_PORT") {
|
||||
if let Ok(p) = v.parse() { self.server.tftp_port = p; }
|
||||
}
|
||||
if let Ok(v) = std::env::var("PXEFORGE_DHCP_PORT") {
|
||||
if let Ok(p) = v.parse() { self.network.dhcp_port = p; }
|
||||
}
|
||||
if let Ok(v) = std::env::var("PXEFORGE_PUBLIC_IP") {
|
||||
if let Ok(ip) = v.parse() { self.server.public_ip = Some(ip); }
|
||||
}
|
||||
if let Ok(v) = std::env::var("PXEFORGE_DHCP_MODE") {
|
||||
self.network.dhcp_mode = match v.to_ascii_lowercase().as_str() {
|
||||
"proxy" => DhcpMode::Proxy,
|
||||
"disabled" | "off" | "none" => DhcpMode::Disabled,
|
||||
_ => self.network.dhcp_mode,
|
||||
};
|
||||
}
|
||||
if let Ok(v) = std::env::var("PXEFORGE_ISO_DIR") {
|
||||
self.paths.iso_dir = PathBuf::from(v);
|
||||
}
|
||||
if let Ok(v) = std::env::var("PXEFORGE_WORK_DIR") {
|
||||
self.paths.work_dir = PathBuf::from(v);
|
||||
}
|
||||
if let Ok(v) = std::env::var("PXEFORGE_IPXE_DIR") {
|
||||
self.paths.ipxe_dir = PathBuf::from(v);
|
||||
}
|
||||
if let Ok(v) = std::env::var("PXEFORGE_SMB_DIR") {
|
||||
self.paths.smb_dir = PathBuf::from(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("config: {0}")]
|
||||
Config(String),
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("invalid input: {0}")]
|
||||
Invalid(String),
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Gated Deployment queue.
|
||||
//!
|
||||
//! When a client selects "Gated Deployment" at the PXE menu, iPXE POSTs to
|
||||
//! `/api/gate/join` and receives a gate position. It then enters a poll
|
||||
//! loop hitting `/api/gate/poll/<id>`; the server holds the request open
|
||||
//! until either (a) the operator assigns an ISO from the WebUI, in which
|
||||
//! case the poll returns an iPXE `chain` URL, or (b) the poll times out
|
||||
//! (iPXE's HTTP client has its own timeout), in which case iPXE re-POSTs.
|
||||
//!
|
||||
//! The WebUI shows the queue (`GET /api/gate`) and issues
|
||||
//! `POST /api/gate/assign { iso_id, gate_ids: [...] }` to launch a single
|
||||
//! ISO across many gated clients at once. This is the "horse-race gate"
|
||||
//! UX the user asked for — every horse leaves the line simultaneously.
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
use tokio::sync::Notify;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ClientArch;
|
||||
|
||||
/// Per-gate state visible to the WebUI.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Gate {
|
||||
pub id: String,
|
||||
/// 1-based race-gate position — position 1 is whoever got there first.
|
||||
pub position: u32,
|
||||
pub mac: String,
|
||||
pub ip: Option<IpAddr>,
|
||||
pub arch: Option<ClientArch>,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub joined_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub last_poll_at: OffsetDateTime,
|
||||
pub assigned_target: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct GateInner {
|
||||
id: String,
|
||||
position: u32,
|
||||
mac: String,
|
||||
ip: Option<IpAddr>,
|
||||
arch: Option<ClientArch>,
|
||||
joined_at: OffsetDateTime,
|
||||
last_poll_at: OffsetDateTime,
|
||||
assigned_target: Option<String>,
|
||||
/// Broadcast primitive that wakes the long-poll as soon as an
|
||||
/// assignment lands — no polling on our side, no sleep-loops.
|
||||
notify: Arc<Notify>,
|
||||
}
|
||||
|
||||
impl GateInner {
|
||||
fn snapshot(&self) -> Gate {
|
||||
Gate {
|
||||
id: self.id.clone(),
|
||||
position: self.position,
|
||||
mac: self.mac.clone(),
|
||||
ip: self.ip,
|
||||
arch: self.arch,
|
||||
joined_at: self.joined_at,
|
||||
last_poll_at: self.last_poll_at,
|
||||
assigned_target: self.assigned_target.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct GateQueue {
|
||||
inner: RwLock<HashMap<String, GateInner>>,
|
||||
}
|
||||
|
||||
impl GateQueue {
|
||||
#[must_use]
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
/// Add a client to the gate. Returns the new `Gate` snapshot. If the
|
||||
/// MAC is already queued, the existing gate is returned unchanged —
|
||||
/// retrying iPXE clients don't duplicate their slot.
|
||||
pub fn join(&self, mac: &str, ip: Option<IpAddr>, arch: Option<ClientArch>) -> Gate {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let mut guard = self.inner.write();
|
||||
|
||||
if let Some(existing) = guard.values_mut().find(|g| g.mac == mac) {
|
||||
existing.last_poll_at = now;
|
||||
if ip.is_some() { existing.ip = ip; }
|
||||
if arch.is_some() { existing.arch = arch; }
|
||||
return existing.snapshot();
|
||||
}
|
||||
|
||||
// Race position = max(position) + 1, or 1 if empty.
|
||||
let next_pos = guard.values().map(|g| g.position).max().unwrap_or(0) + 1;
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let inner = GateInner {
|
||||
id: id.clone(),
|
||||
position: next_pos,
|
||||
mac: mac.to_string(),
|
||||
ip,
|
||||
arch,
|
||||
joined_at: now,
|
||||
last_poll_at: now,
|
||||
assigned_target: None,
|
||||
notify: Arc::new(Notify::new()),
|
||||
};
|
||||
let snap = inner.snapshot();
|
||||
guard.insert(id, inner);
|
||||
snap
|
||||
}
|
||||
|
||||
/// Look up the `Notify` primitive for a given gate id, for long-polling.
|
||||
#[must_use]
|
||||
pub fn notifier(&self, gate_id: &str) -> Option<Arc<Notify>> {
|
||||
self.inner.read().get(gate_id).map(|g| g.notify.clone())
|
||||
}
|
||||
|
||||
/// Update the last-poll timestamp (keeps the gate's "live" indicator
|
||||
/// fresh in the UI) and return the current snapshot. Returns None if
|
||||
/// the gate was released/expired between requests.
|
||||
pub fn touch(&self, gate_id: &str) -> Option<Gate> {
|
||||
let mut guard = self.inner.write();
|
||||
let g = guard.get_mut(gate_id)?;
|
||||
g.last_poll_at = OffsetDateTime::now_utc();
|
||||
Some(g.snapshot())
|
||||
}
|
||||
|
||||
/// Operator assigns an ISO entry (boot_entry id) to one or more gates.
|
||||
/// Returns the number of gates that were updated. Gates not in the
|
||||
/// queue are silently skipped.
|
||||
pub fn assign(&self, gate_ids: &[String], target: &str) -> usize {
|
||||
let mut guard = self.inner.write();
|
||||
let mut updated = 0;
|
||||
for id in gate_ids {
|
||||
if let Some(g) = guard.get_mut(id) {
|
||||
g.assigned_target = Some(target.to_string());
|
||||
g.notify.notify_waiters();
|
||||
updated += 1;
|
||||
}
|
||||
}
|
||||
updated
|
||||
}
|
||||
|
||||
/// Remove a gate and return its final snapshot. Called after the client
|
||||
/// has successfully chained onto its assignment.
|
||||
pub fn release(&self, gate_id: &str) -> Option<Gate> {
|
||||
let mut guard = self.inner.write();
|
||||
let g = guard.remove(gate_id)?;
|
||||
g.notify.notify_waiters();
|
||||
// Renumber positions so the display stays contiguous (1..N). This
|
||||
// is O(N) but the queue is expected to be small (dozens of hosts).
|
||||
let mut remaining: Vec<_> = guard.values_mut().collect();
|
||||
remaining.sort_by_key(|g| g.position);
|
||||
for (i, g) in remaining.iter_mut().enumerate() {
|
||||
g.position = (i + 1) as u32;
|
||||
}
|
||||
Some(g.snapshot())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn list(&self) -> Vec<Gate> {
|
||||
let guard = self.inner.read();
|
||||
let mut v: Vec<_> = guard.values().map(GateInner::snapshot).collect();
|
||||
v.sort_by_key(|g| g.position);
|
||||
v
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.inner.read().len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn join_assigns_sequential_positions() {
|
||||
let q = GateQueue::new();
|
||||
let g1 = q.join("aa:bb:cc:00:00:01", None, None);
|
||||
let g2 = q.join("aa:bb:cc:00:00:02", None, None);
|
||||
let g3 = q.join("aa:bb:cc:00:00:03", None, None);
|
||||
assert_eq!(g1.position, 1);
|
||||
assert_eq!(g2.position, 2);
|
||||
assert_eq!(g3.position, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejoining_same_mac_is_idempotent() {
|
||||
let q = GateQueue::new();
|
||||
let g1 = q.join("aa:bb:cc:00:00:01", None, None);
|
||||
let g2 = q.join("aa:bb:cc:00:00:01", None, None);
|
||||
assert_eq!(g1.id, g2.id);
|
||||
assert_eq!(g1.position, g2.position);
|
||||
assert_eq!(q.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assign_broadcasts_target() {
|
||||
let q = GateQueue::new();
|
||||
let g1 = q.join("aa:bb:cc:00:00:01", None, None);
|
||||
let g2 = q.join("aa:bb:cc:00:00:02", None, None);
|
||||
let n = q.assign(&[g1.id.clone(), g2.id.clone()], "ubuntu-24-04-linux");
|
||||
assert_eq!(n, 2);
|
||||
for g in q.list() {
|
||||
assert_eq!(g.assigned_target.as_deref(), Some("ubuntu-24-04-linux"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_renumbers() {
|
||||
let q = GateQueue::new();
|
||||
let a = q.join("aa:00:00:00:00:01", None, None);
|
||||
let _b = q.join("aa:00:00:00:00:02", None, None);
|
||||
let c = q.join("aa:00:00:00:00:03", None, None);
|
||||
q.release(&a.id);
|
||||
let list = q.list();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].position, 1);
|
||||
assert_eq!(list[1].position, 2);
|
||||
// c had position 3, now renumbered to 2.
|
||||
assert_eq!(list[1].id, c.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn assign_wakes_waiter() {
|
||||
let q = GateQueue::new();
|
||||
let g = q.join("aa:00:00:00:00:01", None, None);
|
||||
let notify = q.notifier(&g.id).unwrap();
|
||||
|
||||
let q2 = Arc::new(q);
|
||||
let q3 = q2.clone();
|
||||
let id = g.id.clone();
|
||||
let fut = tokio::spawn(async move {
|
||||
notify.notified().await;
|
||||
q3.touch(&id)
|
||||
});
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
q2.assign(&[g.id.clone()], "x");
|
||||
let result = fut.await.unwrap();
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap().assigned_target.as_deref(), Some("x"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! PXEForge shared core: config, arch detection, client state registry,
|
||||
//! runtime settings, and the Gated Deployment queue.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod arch;
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod gate;
|
||||
pub mod log_bus;
|
||||
pub mod settings;
|
||||
|
||||
pub use arch::{ClientArch, FirmwareClass};
|
||||
pub use client::{ClientEvent, ClientRegistry, ClientSnapshot};
|
||||
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
|
||||
pub use error::{Error, Result};
|
||||
pub use gate::{Gate, GateQueue};
|
||||
pub use log_bus::{LogBus, LogBusLayer, LogLine};
|
||||
pub use settings::{Settings, SettingsStore, TimeoutAction};
|
||||
@@ -0,0 +1,200 @@
|
||||
//! In-process log bus.
|
||||
//!
|
||||
//! The web UI's Terminal tab streams live server logs over SSE. To feed it
|
||||
//! we install a `tracing_subscriber::Layer` that captures formatted lines
|
||||
//! and pushes them onto:
|
||||
//!
|
||||
//! 1. A bounded `tokio::sync::broadcast` channel for live subscribers.
|
||||
//! 2. A small in-memory ring buffer (default 500 lines) so a UI that
|
||||
//! connects mid-session sees recent context, not a blank pane.
|
||||
//!
|
||||
//! No file logging happens here — Docker/OpenShift already capture stdout.
|
||||
//! This is purely an extra fan-out for the UI.
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::field::{Field, Visit};
|
||||
use tracing::{Event, Level, Subscriber};
|
||||
use tracing_subscriber::layer::Context;
|
||||
use tracing_subscriber::registry::LookupSpan;
|
||||
use tracing_subscriber::Layer;
|
||||
|
||||
/// One captured log line. Cheap to clone (small struct, short strings).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LogLine {
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub timestamp: OffsetDateTime,
|
||||
/// Lowercase: `error`, `warn`, `info`, `debug`, `trace`.
|
||||
pub level: String,
|
||||
pub target: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl LogLine {
|
||||
/// Compact one-line "tail -f"-style render.
|
||||
#[must_use]
|
||||
pub fn render(&self) -> String {
|
||||
// 2026-04-29T12:34:56Z [info] pxeforge::http: HTTP listening on 0.0.0.0:80
|
||||
let ts = self
|
||||
.timestamp
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap_or_else(|_| "?".into());
|
||||
format!(
|
||||
"{ts} [{:>5}] {}: {}",
|
||||
self.level, self.target, self.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LogBus {
|
||||
tx: broadcast::Sender<LogLine>,
|
||||
buf: Mutex<VecDeque<LogLine>>,
|
||||
cap: usize,
|
||||
}
|
||||
|
||||
impl LogBus {
|
||||
#[must_use]
|
||||
pub fn new(capacity: usize) -> Arc<Self> {
|
||||
// 256 = upper bound on concurrent live subscribers' lag tolerance.
|
||||
// If a slow client falls behind it'll get a Lagged error and skip
|
||||
// ahead — which is what we want for a live tail.
|
||||
let (tx, _) = broadcast::channel(256);
|
||||
Arc::new(Self {
|
||||
tx,
|
||||
buf: Mutex::new(VecDeque::with_capacity(capacity)),
|
||||
cap: capacity,
|
||||
})
|
||||
}
|
||||
|
||||
/// Subscribe to new log lines as they're emitted.
|
||||
#[must_use]
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<LogLine> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
|
||||
/// Snapshot of the recent ring buffer (oldest → newest).
|
||||
#[must_use]
|
||||
pub fn recent(&self) -> Vec<LogLine> {
|
||||
self.buf.lock().iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// Drop everything in the recent ring buffer.
|
||||
pub fn clear(&self) {
|
||||
self.buf.lock().clear();
|
||||
}
|
||||
|
||||
/// Manually push a synthetic log line (used by the terminal-command
|
||||
/// handler so operator commands appear inline in the live tail).
|
||||
pub fn push(&self, level: &str, target: &str, message: impl Into<String>) {
|
||||
let line = LogLine {
|
||||
timestamp: OffsetDateTime::now_utc(),
|
||||
level: level.to_string(),
|
||||
target: target.to_string(),
|
||||
message: message.into(),
|
||||
};
|
||||
self.record(line);
|
||||
}
|
||||
|
||||
fn record(&self, line: LogLine) {
|
||||
{
|
||||
let mut g = self.buf.lock();
|
||||
if g.len() == self.cap {
|
||||
g.pop_front();
|
||||
}
|
||||
g.push_back(line.clone());
|
||||
}
|
||||
// Send errors are fine — just means no live subscribers right now.
|
||||
let _ = self.tx.send(line);
|
||||
}
|
||||
}
|
||||
|
||||
/// `tracing_subscriber::Layer` that funnels every event into the LogBus.
|
||||
///
|
||||
/// Install once in `main` alongside the existing `fmt::layer()` so console
|
||||
/// output and the UI tail see the same stream.
|
||||
pub struct LogBusLayer {
|
||||
bus: Arc<LogBus>,
|
||||
}
|
||||
|
||||
impl LogBusLayer {
|
||||
#[must_use]
|
||||
pub fn new(bus: Arc<LogBus>) -> Self {
|
||||
Self { bus }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for LogBusLayer
|
||||
where
|
||||
S: Subscriber + for<'a> LookupSpan<'a>,
|
||||
{
|
||||
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
||||
let meta = event.metadata();
|
||||
let level = match *meta.level() {
|
||||
Level::ERROR => "error",
|
||||
Level::WARN => "warn",
|
||||
Level::INFO => "info",
|
||||
Level::DEBUG => "debug",
|
||||
Level::TRACE => "trace",
|
||||
};
|
||||
let mut visitor = MessageVisitor::default();
|
||||
event.record(&mut visitor);
|
||||
let line = LogLine {
|
||||
timestamp: OffsetDateTime::now_utc(),
|
||||
level: level.to_string(),
|
||||
target: meta.target().to_string(),
|
||||
message: visitor.message,
|
||||
};
|
||||
self.bus.record(line);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MessageVisitor {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl Visit for MessageVisitor {
|
||||
fn record_str(&mut self, field: &Field, value: &str) {
|
||||
if field.name() == "message" {
|
||||
self.message = value.to_string();
|
||||
}
|
||||
}
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
if field.name() == "message" {
|
||||
self.message = format!("{value:?}").trim_matches('"').to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn push_and_recent() {
|
||||
let bus = LogBus::new(3);
|
||||
bus.push("info", "test", "first");
|
||||
bus.push("info", "test", "second");
|
||||
bus.push("info", "test", "third");
|
||||
bus.push("info", "test", "fourth");
|
||||
let r = bus.recent();
|
||||
assert_eq!(r.len(), 3);
|
||||
assert_eq!(r[0].message, "second");
|
||||
assert_eq!(r[2].message, "fourth");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn subscribe_sees_new_lines() {
|
||||
let bus = LogBus::new(8);
|
||||
let mut rx = bus.subscribe();
|
||||
bus.push("info", "test", "live");
|
||||
let l = rx.recv().await.unwrap();
|
||||
assert_eq!(l.message, "live");
|
||||
assert_eq!(l.level, "info");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! 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 `<work_dir>/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<Settings>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
let path = work_dir.join("settings.json");
|
||||
let initial = match std::fs::read_to_string(&path) {
|
||||
Ok(text) => match serde_json::from_str::<Settings>(&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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user