Name update

This commit is contained in:
Miles Ward
2026-04-29 02:47:00 -04:00
commit 3517c67831
66 changed files with 9016 additions and 0 deletions
+26
View File
@@ -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"
+151
View File
@@ -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);
}
}
+100
View File
@@ -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()
}
}
+168
View File
@@ -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);
}
}
}
+17
View File
@@ -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>;
+254
View File
@@ -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"));
}
}
+19
View File
@@ -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};
+200
View File
@@ -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");
}
}
+187
View File
@@ -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);
}
}