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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "pxeforge-dhcp-proxy"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "DHCP proxy (RFC 4578) for PXEForge — serves boot info, does not lease IPs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
pxeforge-core.workspace = true
|
||||
tokio.workspace = true
|
||||
socket2.workspace = true
|
||||
dhcproto.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
bytes.workspace = true
|
||||
@@ -0,0 +1,23 @@
|
||||
//! DHCP proxy (RFC 4578 "PXE Boot Server Discovery").
|
||||
//!
|
||||
//! Listens on UDP/67 (broadcast) and UDP/4011 (PXE boot server). Never
|
||||
//! assigns IPs — only returns boot parameters (siaddr, option 66 TFTP server,
|
||||
//! option 67 boot filename, and the mandatory option 60 "PXEClient" echo).
|
||||
//!
|
||||
//! Key decisions (see architecture memory for rationale):
|
||||
//! - Single code path handles both 67 and 4011; distinguished by port.
|
||||
//! - We set `SO_REUSEADDR` + `SO_BROADCAST` and enable `IP_PKTINFO` so we can
|
||||
//! (a) learn the destination interface for multi-homed pods and (b) reply
|
||||
//! back through the correct interface. This lets us run behind host-network
|
||||
//! in OpenShift without needing `SO_BINDTODEVICE` (which requires NET_RAW).
|
||||
//! - Classification is: option 77 user-class `iPXE` → serve HTTP script URL;
|
||||
//! option 60 starts `HTTPClient` → serve HTTP URL directly (UEFI HTTP boot);
|
||||
//! otherwise → TFTP + arch-specific iPXE binary.
|
||||
//! - We MUST echo `option 60 = "PXEClient"` (or `"HTTPClient"`) in replies or
|
||||
//! clients silently drop them.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod reply;
|
||||
pub mod server;
|
||||
|
||||
pub use server::DhcpProxyServer;
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Build DHCP proxy replies.
|
||||
//!
|
||||
//! Proxy replies look like a normal DHCPOFFER/ACK except:
|
||||
//! - `yiaddr` (your IP) is 0 — we don't lease.
|
||||
//! - `siaddr` (server IP) is us — the client will TFTP from here.
|
||||
//! - option 60 (vendor class) MUST be echoed as `PXEClient` or clients drop.
|
||||
//! - option 66 (TFTP server name) points at us.
|
||||
//! - option 67 (bootfile name) is per-architecture iPXE binary on first
|
||||
//! pass, or the HTTP URL of the boot script once iPXE has chained.
|
||||
|
||||
use dhcproto::v4::{DhcpOption, Message, MessageType, Opcode, OptionCode};
|
||||
use pxeforge_core::{ClientArch, FirmwareClass};
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
/// Where the reply directs the client next.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BootDirective {
|
||||
/// Serve an iPXE binary over TFTP (first-stage chainload).
|
||||
TftpIpxe { filename: String },
|
||||
/// Serve an iPXE boot script directly over HTTP. Used when the client is
|
||||
/// iPXE itself (option 77 = "iPXE") or UEFI HTTP boot (option 60 starts
|
||||
/// with "HTTPClient").
|
||||
HttpScript { url: String },
|
||||
/// Refuse to respond (architecture we don't have a binary for, or
|
||||
/// not-a-PXE-client). Caller should skip sending anything.
|
||||
Ignore,
|
||||
}
|
||||
|
||||
pub struct ReplyContext<'a> {
|
||||
pub request: &'a Message,
|
||||
pub our_ip: Ipv4Addr,
|
||||
pub arch: ClientArch,
|
||||
pub class: FirmwareClass,
|
||||
/// Public base URL (scheme://host[:port]) used in HTTP directives.
|
||||
pub public_base_url: &'a str,
|
||||
}
|
||||
|
||||
/// Decide what to do for an incoming request. Pure function — easy to unit
|
||||
/// test. Does NOT send anything.
|
||||
#[must_use]
|
||||
pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
|
||||
match ctx.class {
|
||||
FirmwareClass::IpxeUserClass => BootDirective::HttpScript {
|
||||
url: format!("{}/boot.ipxe", ctx.public_base_url.trim_end_matches('/')),
|
||||
},
|
||||
FirmwareClass::HttpClient => {
|
||||
// UEFI HTTP boot: client wants an http:// URL in option 67
|
||||
// pointing at an EFI executable. We serve ipxe.efi over HTTP;
|
||||
// it'll then do the same script-fetch the iPXE path does.
|
||||
let name = ctx.arch.ipxe_bootfile().unwrap_or("snponly.efi");
|
||||
BootDirective::HttpScript {
|
||||
url: format!("{}/ipxe/{}", ctx.public_base_url.trim_end_matches('/'), name),
|
||||
}
|
||||
}
|
||||
FirmwareClass::PxeClient => match ctx.arch.ipxe_bootfile() {
|
||||
Some(name) => BootDirective::TftpIpxe { filename: name.to_string() },
|
||||
None => BootDirective::Ignore,
|
||||
},
|
||||
FirmwareClass::Other => BootDirective::Ignore,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the outgoing DHCPOFFER (or ACK, matching request type) for a
|
||||
/// directive. Caller is responsible for sending the bytes on the wire.
|
||||
pub fn build_reply(ctx: &ReplyContext<'_>, directive: &BootDirective) -> Option<Message> {
|
||||
let reply_type = match request_message_type(ctx.request)? {
|
||||
MessageType::Discover => MessageType::Offer,
|
||||
MessageType::Request | MessageType::Inform => MessageType::Ack,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let mut msg = Message::default();
|
||||
msg.set_opcode(Opcode::BootReply)
|
||||
.set_htype(ctx.request.htype())
|
||||
.set_hops(0)
|
||||
.set_xid(ctx.request.xid())
|
||||
.set_secs(0)
|
||||
.set_flags(ctx.request.flags())
|
||||
.set_ciaddr(Ipv4Addr::UNSPECIFIED)
|
||||
.set_yiaddr(Ipv4Addr::UNSPECIFIED) // proxy does not lease
|
||||
.set_siaddr(ctx.our_ip)
|
||||
.set_giaddr(ctx.request.giaddr())
|
||||
.set_chaddr(ctx.request.chaddr());
|
||||
|
||||
// Set the BOOTP `file` field for legacy PXE stacks before we take the
|
||||
// options borrow (the two borrows can't overlap).
|
||||
if let BootDirective::TftpIpxe { filename } = directive {
|
||||
msg.set_fname_str(filename);
|
||||
}
|
||||
|
||||
let class_echo: &[u8] = match ctx.class {
|
||||
FirmwareClass::HttpClient => b"HTTPClient",
|
||||
_ => b"PXEClient",
|
||||
};
|
||||
let opts = msg.opts_mut();
|
||||
opts.insert(DhcpOption::MessageType(reply_type));
|
||||
opts.insert(DhcpOption::ServerIdentifier(ctx.our_ip));
|
||||
// Echo the vendor class — REQUIRED by spec for the client to accept.
|
||||
opts.insert(DhcpOption::ClassIdentifier(class_echo.to_vec()));
|
||||
|
||||
match directive {
|
||||
BootDirective::TftpIpxe { filename } => {
|
||||
opts.insert(DhcpOption::TFTPServerName(ctx.our_ip.to_string().into_bytes()));
|
||||
opts.insert(DhcpOption::BootfileName(filename.as_bytes().to_vec()));
|
||||
}
|
||||
BootDirective::HttpScript { url } => {
|
||||
opts.insert(DhcpOption::BootfileName(url.as_bytes().to_vec()));
|
||||
opts.insert(DhcpOption::TFTPServerName(ctx.our_ip.to_string().into_bytes()));
|
||||
}
|
||||
BootDirective::Ignore => return None,
|
||||
}
|
||||
|
||||
opts.insert(DhcpOption::End);
|
||||
Some(msg)
|
||||
}
|
||||
|
||||
fn request_message_type(m: &Message) -> Option<MessageType> {
|
||||
m.opts().get(OptionCode::MessageType).and_then(|o| match o {
|
||||
DhcpOption::MessageType(t) => Some(*t),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! UDP listener loop for the DHCP proxy. Accepts on :67 (and :4011 on a
|
||||
//! second socket) and dispatches each datagram through the pure reply logic.
|
||||
|
||||
use crate::reply::{build_reply, decide, BootDirective, ReplyContext};
|
||||
use dhcproto::v4::{DhcpOption, Message, OptionCode};
|
||||
use dhcproto::{Decodable, Decoder, Encodable, Encoder};
|
||||
use pxeforge_core::{
|
||||
ClientArch, ClientEvent, ClientRegistry, FirmwareClass,
|
||||
};
|
||||
use socket2::{Domain, Protocol, Socket, Type};
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
pub struct DhcpProxyServer {
|
||||
bind: IpAddr,
|
||||
dhcp_port: u16,
|
||||
pxe_port: u16,
|
||||
our_ip: Ipv4Addr,
|
||||
public_base_url: String,
|
||||
clients: Arc<ClientRegistry>,
|
||||
}
|
||||
|
||||
impl DhcpProxyServer {
|
||||
pub fn new(
|
||||
bind: IpAddr,
|
||||
dhcp_port: u16,
|
||||
pxe_port: u16,
|
||||
our_ip: Ipv4Addr,
|
||||
public_base_url: String,
|
||||
clients: Arc<ClientRegistry>,
|
||||
) -> Self {
|
||||
Self { bind, dhcp_port, pxe_port, our_ip, public_base_url, clients }
|
||||
}
|
||||
|
||||
pub async fn run(self) -> anyhow::Result<()> {
|
||||
let dhcp_sock = bind_udp(self.bind, self.dhcp_port, true)?;
|
||||
let pxe_sock = bind_udp(self.bind, self.pxe_port, false)?;
|
||||
tracing::info!(
|
||||
target: "pxeforge::dhcp",
|
||||
"DHCP proxy listening on {}:{} and :{}",
|
||||
self.bind, self.dhcp_port, self.pxe_port
|
||||
);
|
||||
|
||||
let ctx = Arc::new(self);
|
||||
let c1 = ctx.clone();
|
||||
let c2 = ctx.clone();
|
||||
let a = tokio::spawn(async move { c1.serve_loop(dhcp_sock, "67").await });
|
||||
let b = tokio::spawn(async move { c2.serve_loop(pxe_sock, "4011").await });
|
||||
let _ = tokio::try_join!(a, b)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn serve_loop(&self, sock: UdpSocket, label: &'static str) -> anyhow::Result<()> {
|
||||
let mut buf = vec![0u8; 4096];
|
||||
loop {
|
||||
let (n, from) = match sock.recv_from(&mut buf).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(target: "pxeforge::dhcp", port=label, "recv error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(e) = self.handle_datagram(&sock, &buf[..n], from, label).await {
|
||||
tracing::warn!(target: "pxeforge::dhcp", port=label, "handle error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_datagram(
|
||||
&self,
|
||||
sock: &UdpSocket,
|
||||
data: &[u8],
|
||||
from: SocketAddr,
|
||||
label: &'static str,
|
||||
) -> anyhow::Result<()> {
|
||||
let request = Message::decode(&mut Decoder::new(data))?;
|
||||
|
||||
let vendor_class = request.opts().get(OptionCode::ClassIdentifier).and_then(|o| {
|
||||
if let DhcpOption::ClassIdentifier(v) = o { Some(v.as_slice()) } else { None }
|
||||
});
|
||||
let user_class = request.opts().get(OptionCode::UserClass).and_then(|o| {
|
||||
if let DhcpOption::UserClass(v) = o { Some(v.as_slice()) } else { None }
|
||||
});
|
||||
let class = FirmwareClass::classify(vendor_class, user_class);
|
||||
if matches!(class, FirmwareClass::Other) {
|
||||
// Not a PXE client (e.g. a regular DHCP DISCOVER from a phone).
|
||||
// Silently ignore — we are a proxy, we only speak to PXE clients.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// dhcproto types option 93 as an enum that drops unknown codes;
|
||||
// re-parse from the raw wire bytes so firmware quirks like 0x0009
|
||||
// come through intact.
|
||||
let raw_arch = extract_raw_arch(data).unwrap_or(0);
|
||||
let arch = ClientArch::from_option_93(raw_arch);
|
||||
|
||||
let chaddr = request.chaddr();
|
||||
let mac = format_mac(chaddr);
|
||||
self.clients.record(
|
||||
&mac,
|
||||
None,
|
||||
Some(arch),
|
||||
match label {
|
||||
"4011" => ClientEvent::PxeBootServerRequest,
|
||||
_ => ClientEvent::DhcpDiscover,
|
||||
},
|
||||
);
|
||||
|
||||
let ctx = ReplyContext {
|
||||
request: &request,
|
||||
our_ip: self.our_ip,
|
||||
arch,
|
||||
class,
|
||||
public_base_url: &self.public_base_url,
|
||||
};
|
||||
let directive = decide(&ctx);
|
||||
if matches!(directive, BootDirective::Ignore) {
|
||||
tracing::debug!(
|
||||
target: "pxeforge::dhcp",
|
||||
mac=%mac, arch=?arch, "ignoring — no bootfile for arch"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(reply) = build_reply(&ctx, &directive) else { return Ok(()); };
|
||||
let mut out = Vec::with_capacity(512);
|
||||
reply.encode(&mut Encoder::new(&mut out))?;
|
||||
|
||||
let dest = reply_destination(&request, from);
|
||||
sock.send_to(&out, dest).await?;
|
||||
tracing::info!(
|
||||
target: "pxeforge::dhcp",
|
||||
mac=%mac, arch=arch.as_str(), class=?class, dest=%dest, directive=?directive,
|
||||
"PXE reply sent"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Choose where to send the reply. DHCP semantics (RFC 2131 §4.1):
|
||||
/// 1. If the request came via a relay agent (`giaddr` != 0), reply to
|
||||
/// that agent on port 67. The relay will forward to the client.
|
||||
/// 2. If the client already has an IP (`ciaddr`), unicast there on :68.
|
||||
/// 3. If the broadcast flag is set in the BOOTP flags (bit 15), the
|
||||
/// client cannot receive unicast frames yet — we MUST broadcast.
|
||||
/// 4. Otherwise, per the spec we MAY unicast to `chaddr` if we ARP-inject,
|
||||
/// but since we don't craft raw frames (proxy mode, no NET_RAW), we
|
||||
/// fall back to broadcast which every client accepts.
|
||||
/// 5. Special case for the PXE Boot Server port 4011: reply to the
|
||||
/// source address/port exactly — this is a unicast query and the
|
||||
/// client expects a unicast answer there.
|
||||
fn reply_destination(request: &Message, from: SocketAddr) -> SocketAddr {
|
||||
// (1) relayed request
|
||||
let giaddr = request.giaddr();
|
||||
if giaddr != Ipv4Addr::UNSPECIFIED {
|
||||
return SocketAddr::V4(SocketAddrV4::new(giaddr, 67));
|
||||
}
|
||||
// (5) PXE Boot Server discovery is unicast
|
||||
if from.port() == 4011 {
|
||||
return from;
|
||||
}
|
||||
// (2) client has an IP and has NOT requested broadcast-only
|
||||
let ciaddr = request.ciaddr();
|
||||
let bflag = request.flags().broadcast();
|
||||
if ciaddr != Ipv4Addr::UNSPECIFIED && !bflag {
|
||||
return SocketAddr::V4(SocketAddrV4::new(ciaddr, 68));
|
||||
}
|
||||
// (3, 4) broadcast to 255.255.255.255:68
|
||||
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::BROADCAST, 68))
|
||||
}
|
||||
|
||||
fn bind_udp(bind: IpAddr, port: u16, broadcast: bool) -> anyhow::Result<UdpSocket> {
|
||||
let domain = match bind {
|
||||
IpAddr::V4(_) => Domain::IPV4,
|
||||
IpAddr::V6(_) => Domain::IPV6,
|
||||
};
|
||||
let sock = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP))?;
|
||||
sock.set_reuse_address(true)?;
|
||||
#[cfg(unix)]
|
||||
sock.set_reuse_port(true)?;
|
||||
if broadcast {
|
||||
sock.set_broadcast(true)?;
|
||||
}
|
||||
sock.set_nonblocking(true)?;
|
||||
let addr: SocketAddr = SocketAddr::new(bind, port);
|
||||
sock.bind(&addr.into())?;
|
||||
let std_sock: std::net::UdpSocket = sock.into();
|
||||
Ok(UdpSocket::from_std(std_sock)?)
|
||||
}
|
||||
|
||||
fn format_mac(chaddr: &[u8]) -> String {
|
||||
let take = chaddr.iter().take(6).copied().collect::<Vec<_>>();
|
||||
take.iter().map(|b| format!("{b:02x}")).collect::<Vec<_>>().join(":")
|
||||
}
|
||||
|
||||
/// Walk raw DHCP options looking for option 93 (Client System Architecture)
|
||||
/// and return the first 2-byte big-endian value. This bypasses dhcproto's
|
||||
/// typed decoding because some firmwares emit values outside the IANA table
|
||||
/// that the typed decoder may drop.
|
||||
fn extract_raw_arch(packet: &[u8]) -> Option<u16> {
|
||||
// DHCPv4 fixed header is 240 bytes including the 4-byte magic cookie.
|
||||
// Options start at offset 240.
|
||||
let opts = packet.get(240..)?;
|
||||
let mut i = 0;
|
||||
while i < opts.len() {
|
||||
let code = opts[i];
|
||||
if code == 0xff { return None; } // END
|
||||
if code == 0x00 { i += 1; continue; } // PAD
|
||||
i += 1;
|
||||
if i >= opts.len() { return None; }
|
||||
let len = opts[i] as usize;
|
||||
i += 1;
|
||||
if code == 93 && len >= 2 && i + 2 <= opts.len() {
|
||||
return Some(u16::from_be_bytes([opts[i], opts[i + 1]]));
|
||||
}
|
||||
i += len;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extracts_arch_from_raw_options() {
|
||||
// Minimal BOOTP header + magic cookie + option 93 (arch)=0x0007 + END.
|
||||
let mut pkt = vec![0u8; 240];
|
||||
pkt[236..240].copy_from_slice(&[99, 130, 83, 99]); // magic cookie
|
||||
pkt.extend_from_slice(&[53, 1, 1]); // option 53 DHCPDISCOVER
|
||||
pkt.extend_from_slice(&[93, 2, 0x00, 0x07]);
|
||||
pkt.push(0xff);
|
||||
assert_eq!(extract_raw_arch(&pkt), Some(0x0007));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
[package]
|
||||
name = "pxeforge-http-api"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "HTTP server: ISO uploads, iPXE script generation, ISO streaming"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
pxeforge-core.workspace = true
|
||||
pxeforge-iso-store.workspace = true
|
||||
pxeforge-ipxe-assets.workspace = true
|
||||
pxeforge-webui.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-util.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
time.workspace = true
|
||||
axum.workspace = true
|
||||
tower.workspace = true
|
||||
tower-http.workspace = true
|
||||
hyper.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
bytes.workspace = true
|
||||
futures.workspace = true
|
||||
mime.workspace = true
|
||||
mime_guess.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] }
|
||||
tower = { workspace = true }
|
||||
tempfile = "3.12"
|
||||
serde_json = { workspace = true }
|
||||
time = { workspace = true }
|
||||
@@ -0,0 +1,680 @@
|
||||
//! Axum router, handlers, and API endpoints.
|
||||
//!
|
||||
//! Route groups:
|
||||
//!
|
||||
//! | Group | Purpose |
|
||||
//! |------------------|-------------------------------------------------------|
|
||||
//! | `/` | Web UI (served from `pxeforge-webui`) |
|
||||
//! | `/boot.ipxe` | Top-level iPXE menu |
|
||||
//! | `/boot/_*.ipxe` | Submenu scripts (hierarchy: linux, windows, tools, …) |
|
||||
//! | `/boot/<id>.ipxe`| Per-entry boot script |
|
||||
//! | `/ipxe/<file>` | Bundled iPXE binaries + wimboot + memtest |
|
||||
//! | `/iso/<id>.iso` | Raw ISO with Range support |
|
||||
//! | `/iso/<id>/*` | Files inside the ISO (for wimboot & kernel/initrd) |
|
||||
//! | `/api/*` | JSON/HTML API for the web UI |
|
||||
|
||||
use crate::ipxe_script::{
|
||||
render_entry, render_family_menu, render_gate_entry, render_local_hdd,
|
||||
render_menu, render_nic_info, render_shell, render_tools_menu, render_util,
|
||||
};
|
||||
use crate::iso_fs;
|
||||
use crate::log_stream;
|
||||
use crate::state::AppState;
|
||||
use crate::terminal;
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{DefaultBodyLimit, Multipart, Path as AxumPath, Query, State},
|
||||
http::{header, HeaderMap, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use pxeforge_core::{ClientEvent, Settings};
|
||||
use pxeforge_ipxe_assets::asset_bytes;
|
||||
use pxeforge_iso_store::{IsoMeta, NfsAddRequest};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
// Web UI (fully offline — no CDN, no external fonts/images).
|
||||
.route("/", get(index))
|
||||
.route("/assets/app.js", get(ui_js))
|
||||
.route("/assets/app.css", get(ui_css))
|
||||
.route("/assets/logo.svg", get(ui_logo))
|
||||
// iPXE script endpoints.
|
||||
.route("/boot.ipxe", get(boot_top_menu))
|
||||
.route("/boot/:filename", get(boot_sub))
|
||||
// Bundled binaries and raw ISO access.
|
||||
.route("/ipxe/:name", get(ipxe_binary))
|
||||
.route("/iso/:filename", get(iso_raw))
|
||||
.route("/iso/:id/*path", get(iso_file))
|
||||
// Container health/readiness probes. `/healthz` is always 200 OK
|
||||
// while the HTTP task is alive. `/readyz` additionally requires at
|
||||
// least one bundled iPXE binary (without one, no client can PXE).
|
||||
.route("/healthz", get(healthz))
|
||||
.route("/readyz", get(readyz))
|
||||
// JSON API.
|
||||
.route("/api/isos", get(api_list_isos).post(api_upload_iso))
|
||||
.route("/api/isos/:id", delete(api_delete_iso))
|
||||
.route("/api/clients", get(api_list_clients))
|
||||
.route("/api/status", get(api_status))
|
||||
.route("/api/settings", get(api_get_settings).put(api_put_settings))
|
||||
.route("/api/gate", get(api_list_gates))
|
||||
.route("/api/gate/join", get(api_gate_join))
|
||||
.route("/api/gate/poll/:gate_id", get(api_gate_poll))
|
||||
.route("/api/gate/assign", post(api_gate_assign))
|
||||
.route("/api/gate/:gate_id", delete(api_gate_release))
|
||||
// Phase 4: NFS share manager.
|
||||
.route("/api/nfs", get(api_nfs_list).post(api_nfs_add))
|
||||
.route("/api/nfs/:id", delete(api_nfs_remove))
|
||||
.route("/api/nfs/:id/scan", post(api_nfs_scan))
|
||||
// Phase 4: Network info (read-only) + DNS edit.
|
||||
.route("/api/network", get(api_network).put(api_network_put))
|
||||
// Phase 4: live-log stream + recent buffer for the Terminal tab.
|
||||
.route("/api/log/stream", get(log_stream::stream))
|
||||
.route("/api/log/recent", get(log_stream::recent))
|
||||
.route("/api/log/clear", post(log_stream::clear))
|
||||
// Phase 4: operator terminal commands (whitelisted).
|
||||
.route("/api/terminal", post(terminal::run_command))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
// 16 GiB upload cap — ISOs are big; chunks stream so this isn't memory use.
|
||||
.layer(DefaultBodyLimit::max(16 * 1024 * 1024 * 1024))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ─── UI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn index(State(state): State<AppState>) -> Response {
|
||||
let html = pxeforge_webui::index_html(&state.public_base_url);
|
||||
([(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"))], html)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn ui_js() -> Response {
|
||||
(
|
||||
[(header::CONTENT_TYPE, HeaderValue::from_static("application/javascript"))],
|
||||
pxeforge_webui::app_js(),
|
||||
).into_response()
|
||||
}
|
||||
|
||||
async fn ui_css() -> Response {
|
||||
(
|
||||
[(header::CONTENT_TYPE, HeaderValue::from_static("text/css"))],
|
||||
pxeforge_webui::app_css(),
|
||||
).into_response()
|
||||
}
|
||||
|
||||
async fn ui_logo() -> Response {
|
||||
(
|
||||
[(header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml"))],
|
||||
pxeforge_webui::logo_svg(),
|
||||
).into_response()
|
||||
}
|
||||
|
||||
// ─── iPXE scripts ──────────────────────────────────────────────────────────
|
||||
|
||||
fn text_plain(body: String) -> Response {
|
||||
([(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8"))], body)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn boot_top_menu(State(state): State<AppState>) -> Response {
|
||||
let isos = state.iso_store.list();
|
||||
let settings = state.settings.snapshot();
|
||||
text_plain(render_menu(&isos, &settings, &state.public_base_url))
|
||||
}
|
||||
|
||||
async fn boot_sub(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(filename): AxumPath<String>,
|
||||
) -> Response {
|
||||
// `/boot/<name>.ipxe` where `<name>` is either one of our reserved
|
||||
// submenu names (prefixed `_`) or a boot entry id.
|
||||
let name = filename.strip_suffix(".ipxe").unwrap_or(&filename);
|
||||
let isos = state.iso_store.list();
|
||||
let settings = state.settings.snapshot();
|
||||
let base = &state.public_base_url;
|
||||
let script = match name {
|
||||
"_local" => render_local_hdd(base),
|
||||
"_linux_menu" => render_family_menu(&isos, base, false),
|
||||
"_windows_menu" => render_family_menu(&isos, base, true),
|
||||
"_tools_menu" => render_tools_menu(base),
|
||||
"_util" => render_util(base),
|
||||
"_shell" => render_shell(base),
|
||||
"_nic" => render_nic_info(base),
|
||||
"_gate" => render_gate_entry(base),
|
||||
other => {
|
||||
for iso in &isos {
|
||||
for entry in &iso.boot_entries {
|
||||
if entry.id == other {
|
||||
return text_plain(render_entry(entry, &settings, base));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (StatusCode::NOT_FOUND, "no such boot entry").into_response();
|
||||
}
|
||||
};
|
||||
text_plain(script)
|
||||
}
|
||||
|
||||
// ─── bundled iPXE binaries (memtest lives here too) ───────────────────────
|
||||
|
||||
async fn ipxe_binary(AxumPath(name): AxumPath<String>) -> Response {
|
||||
if name.contains('/') || name.contains('\\') {
|
||||
return (StatusCode::BAD_REQUEST, "invalid name").into_response();
|
||||
}
|
||||
let Some(bytes) = asset_bytes(&name) else {
|
||||
return (StatusCode::NOT_FOUND, "no such ipxe asset").into_response();
|
||||
};
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, HeaderValue::from_static("application/octet-stream")),
|
||||
(header::CONTENT_LENGTH, HeaderValue::from(bytes.len())),
|
||||
],
|
||||
bytes,
|
||||
).into_response()
|
||||
}
|
||||
|
||||
// ─── ISO streaming (raw + in-ISO) ─────────────────────────────────────────
|
||||
|
||||
async fn iso_raw(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(filename): AxumPath<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let id = filename.strip_suffix(".iso").unwrap_or(&filename);
|
||||
let Some(path) = state.iso_store.iso_path_for(id) else {
|
||||
return (StatusCode::NOT_FOUND, "no such iso").into_response();
|
||||
};
|
||||
match stream_file_range(&path, headers.get(header::RANGE)).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn iso_file(
|
||||
State(state): State<AppState>,
|
||||
AxumPath((id, path)): AxumPath<(String, String)>,
|
||||
) -> Response {
|
||||
let Some(iso_path) = state.iso_store.iso_path_for(&id) else {
|
||||
return (StatusCode::NOT_FOUND, "no such iso").into_response();
|
||||
};
|
||||
let p = iso_path.clone();
|
||||
let in_path = format!("/{}", path);
|
||||
let loc = tokio::task::spawn_blocking(move || iso_fs::lookup(&p, &in_path))
|
||||
.await.ok().flatten();
|
||||
let Some(loc) = loc else {
|
||||
return (StatusCode::NOT_FOUND, "not found inside iso").into_response();
|
||||
};
|
||||
match stream_byte_range(&iso_path, loc.offset, loc.length).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn stream_file_range(
|
||||
path: &std::path::Path,
|
||||
range: Option<&HeaderValue>,
|
||||
) -> anyhow::Result<Response> {
|
||||
let meta = tokio::fs::metadata(path).await?;
|
||||
let total = meta.len();
|
||||
let (start, end, partial) = parse_range(range, total);
|
||||
let len = end - start + 1;
|
||||
let mut file = tokio::fs::File::open(path).await?;
|
||||
file.seek(std::io::SeekFrom::Start(start)).await?;
|
||||
let reader = file.take(len);
|
||||
let stream = tokio_util::io::ReaderStream::new(reader);
|
||||
let body = Body::from_stream(stream);
|
||||
let status = if partial { StatusCode::PARTIAL_CONTENT } else { StatusCode::OK };
|
||||
let mut builder = Response::builder()
|
||||
.status(status)
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.header(header::CONTENT_LENGTH, len);
|
||||
if partial {
|
||||
builder = builder.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{total}"));
|
||||
}
|
||||
Ok(builder.body(body).unwrap())
|
||||
}
|
||||
|
||||
async fn stream_byte_range(
|
||||
path: &std::path::Path,
|
||||
offset: u64,
|
||||
length: u64,
|
||||
) -> anyhow::Result<Response> {
|
||||
let mut file = tokio::fs::File::open(path).await?;
|
||||
file.seek(std::io::SeekFrom::Start(offset)).await?;
|
||||
let reader = file.take(length);
|
||||
let stream = tokio_util::io::ReaderStream::new(reader);
|
||||
let body = Body::from_stream(stream);
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.header(header::CONTENT_LENGTH, length)
|
||||
.body(body)
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
fn parse_range(h: Option<&HeaderValue>, total: u64) -> (u64, u64, bool) {
|
||||
let Some(h) = h else { return (0, total.saturating_sub(1), false); };
|
||||
let Ok(s) = h.to_str() else { return (0, total.saturating_sub(1), false); };
|
||||
let Some(spec) = s.strip_prefix("bytes=") else { return (0, total.saturating_sub(1), false); };
|
||||
let spec = spec.split(',').next().unwrap_or("").trim();
|
||||
if let Some(suffix) = spec.strip_prefix('-') {
|
||||
if let Ok(n) = suffix.parse::<u64>() {
|
||||
let n = n.min(total);
|
||||
return (total.saturating_sub(n), total.saturating_sub(1), true);
|
||||
}
|
||||
}
|
||||
let mut parts = spec.splitn(2, '-');
|
||||
let start = parts.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
|
||||
let end = parts.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(total.saturating_sub(1));
|
||||
(start, end.min(total.saturating_sub(1)), true)
|
||||
}
|
||||
|
||||
// ─── ISO upload / list / delete ───────────────────────────────────────────
|
||||
|
||||
async fn api_list_isos(State(state): State<AppState>) -> Json<Vec<IsoMeta>> {
|
||||
Json(state.iso_store.list())
|
||||
}
|
||||
|
||||
async fn api_delete_iso(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> StatusCode {
|
||||
match state.iso_store.delete(&id).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT,
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
async fn api_upload_iso(
|
||||
State(state): State<AppState>,
|
||||
mut multipart: Multipart,
|
||||
) -> Response {
|
||||
while let Ok(Some(mut field)) = multipart.next_field().await {
|
||||
if field.name() != Some("file") { continue; }
|
||||
let filename = field.file_name().unwrap_or("uploaded.iso").to_string();
|
||||
if !filename.to_ascii_lowercase().ends_with(".iso") {
|
||||
return (StatusCode::BAD_REQUEST, "only .iso uploads accepted").into_response();
|
||||
}
|
||||
let mut handle = match state.iso_store.begin_upload(&filename).await {
|
||||
Ok(h) => h,
|
||||
Err(e) => return (StatusCode::CONFLICT, format!("{e}")).into_response(),
|
||||
};
|
||||
while let Ok(Some(chunk)) = field.chunk().await {
|
||||
if let Err(e) = handle.write_chunk(&chunk).await {
|
||||
let _ = handle.abort().await;
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response();
|
||||
}
|
||||
}
|
||||
let meta = match handle.finish(&state.iso_store).await {
|
||||
Ok(m) => m,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||
};
|
||||
return (StatusCode::CREATED, Json(meta)).into_response();
|
||||
}
|
||||
(StatusCode::BAD_REQUEST, "no 'file' part").into_response()
|
||||
}
|
||||
|
||||
// ─── health / readiness ───────────────────────────────────────────────────
|
||||
|
||||
async fn healthz() -> Response {
|
||||
// Simple liveness — HTTP task is responsive. Does not touch storage or
|
||||
// other subsystems so we never fail for downstream reasons.
|
||||
([(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))], "ok\n").into_response()
|
||||
}
|
||||
|
||||
async fn readyz(State(state): State<AppState>) -> Response {
|
||||
// Readiness — can we actually serve clients?
|
||||
// 1. At least one iPXE binary must be bundled (without one, TFTP 404s
|
||||
// and no client boots).
|
||||
// 2. The ISO directory must exist and be readable.
|
||||
let assets = pxeforge_ipxe_assets::list_assets();
|
||||
let mut problems: Vec<&str> = Vec::new();
|
||||
if assets.is_empty() {
|
||||
problems.push("no iPXE binaries bundled (run scripts/fetch-ipxe.sh before building)");
|
||||
}
|
||||
let iso_dir_ok = state.iso_store.list_ok();
|
||||
if !iso_dir_ok {
|
||||
problems.push("iso directory not readable");
|
||||
}
|
||||
if problems.is_empty() {
|
||||
([(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))], "ready\n").into_response()
|
||||
} else {
|
||||
let body = format!("not ready:\n- {}\n", problems.join("\n- "));
|
||||
(StatusCode::SERVICE_UNAVAILABLE, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── clients + status + settings ──────────────────────────────────────────
|
||||
|
||||
async fn api_list_clients(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
Json(json!({ "clients": state.clients.list() }))
|
||||
}
|
||||
|
||||
async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
let smb = state.smb.as_ref().map(|s| s.snapshot());
|
||||
let nfs = state.nfs.list();
|
||||
let nfs_active = nfs.iter().filter(|m| m.mounted).count();
|
||||
let isos = state.iso_store.list();
|
||||
let gates = state.gates.list();
|
||||
// Phase 4: dashboard tracks "imaging" as gates with an assignment
|
||||
// already issued — they're the ones actively chaining a boot script.
|
||||
let imaging = gates.iter().filter(|g| g.assigned_target.is_some()).count();
|
||||
let waiting = gates.len() - imaging;
|
||||
let now = time::OffsetDateTime::now_utc();
|
||||
let uptime_secs = (now - state.started_at).whole_seconds().max(0);
|
||||
Json(json!({
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"public_base_url": state.public_base_url,
|
||||
"iso_count": isos.len(),
|
||||
"client_count": state.clients.list().len(),
|
||||
"gate_count": gates.len(),
|
||||
"imaging_count": imaging,
|
||||
"waiting_count": waiting,
|
||||
"ipxe_assets": pxeforge_ipxe_assets::list_assets(),
|
||||
"settings": state.settings.snapshot(),
|
||||
"smb": smb,
|
||||
"nfs_count": nfs.len(),
|
||||
"nfs_active": nfs_active,
|
||||
"uptime_secs": uptime_secs,
|
||||
"started_at": state.started_at,
|
||||
"nic_name": state.nic_name,
|
||||
"subnet_mask": state.subnet_mask,
|
||||
"gateway": state.gateway,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn api_get_settings(State(state): State<AppState>) -> Json<Settings> {
|
||||
Json(state.settings.snapshot())
|
||||
}
|
||||
|
||||
async fn api_put_settings(
|
||||
State(state): State<AppState>,
|
||||
Json(mut new): Json<Settings>,
|
||||
) -> Response {
|
||||
// Guardrail: Windows boot requires the wimboot shim to be bundled.
|
||||
// Without it, clients chain a non-existent /ipxe/wimboot and stall.
|
||||
if new.windows_enabled {
|
||||
let assets = pxeforge_ipxe_assets::list_assets();
|
||||
if !assets.iter().any(|n| n == "wimboot") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"cannot enable Windows: 'wimboot' binary is not bundled. \
|
||||
Place a signed wimboot build at assets/ipxe/wimboot and rebuild \
|
||||
the container. See docs/architecture.md for details.",
|
||||
).into_response();
|
||||
}
|
||||
}
|
||||
new.smb_host_override = new.smb_host_override.trim().to_string();
|
||||
|
||||
// Detect whether this PUT changes the Windows toggle, so we only
|
||||
// restart smbd when it actually flipped.
|
||||
let was_enabled = state.settings.snapshot().windows_enabled;
|
||||
let want_enabled = new.windows_enabled;
|
||||
state.settings.replace(new);
|
||||
|
||||
if let Some(smb) = &state.smb {
|
||||
match (was_enabled, want_enabled) {
|
||||
(false, true) => { let _ = smb.start(); }
|
||||
(true, false) => { smb.stop(); }
|
||||
(true, true) => { let _ = smb.reconcile(); }
|
||||
(false, false) => {}
|
||||
}
|
||||
}
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
// ─── Gated Deployment API ─────────────────────────────────────────────────
|
||||
|
||||
async fn api_list_gates(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
Json(json!({
|
||||
"gates": state.gates.list(),
|
||||
"count": state.gates.list().len(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GateJoinParams {
|
||||
/// Client MAC from iPXE's `${mac}` variable. iPXE substitutes before
|
||||
/// the HTTP request so we receive a plain colon-separated MAC.
|
||||
mac: Option<String>,
|
||||
}
|
||||
|
||||
/// Called by iPXE via `chain --replace`. We respond with a tiny iPXE
|
||||
/// script that hard-loops on `/api/gate/poll/<id>`. iPXE keeps fetching
|
||||
/// until poll returns an actual boot script.
|
||||
async fn api_gate_join(
|
||||
State(state): State<AppState>,
|
||||
Query(p): Query<GateJoinParams>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let mac = p.mac.unwrap_or_else(|| "unknown".to_string());
|
||||
let ip = headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|s| s.split(',').next())
|
||||
.and_then(|s| s.trim().parse().ok());
|
||||
|
||||
let gate = state.gates.join(&mac, ip, None);
|
||||
state.clients.record(
|
||||
&mac, ip, None,
|
||||
ClientEvent::HttpScriptFetch { target: "gate-join".into() },
|
||||
);
|
||||
|
||||
let base = &state.public_base_url;
|
||||
// ASCII only - some iPXE builds on firmware consoles mangle non-ASCII.
|
||||
let script = format!(
|
||||
"#!ipxe\n\
|
||||
echo\n\
|
||||
echo ==========================================\n\
|
||||
echo Gated Deployment - Gate Position {}\n\
|
||||
echo Waiting for operator to assign an image\n\
|
||||
echo (Ctrl-B returns to the iPXE shell)\n\
|
||||
echo ==========================================\n\
|
||||
chain {base}/api/gate/poll/{}\n",
|
||||
gate.position, gate.id
|
||||
);
|
||||
text_plain(script)
|
||||
}
|
||||
|
||||
/// Long-poll endpoint. Waits up to 25s for an assignment; if none, returns
|
||||
/// a script that loops back to itself. 25s keeps us well inside typical
|
||||
/// HTTP idle timeouts for iPXE and intermediaries.
|
||||
async fn api_gate_poll(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(gate_id): AxumPath<String>,
|
||||
) -> Response {
|
||||
let Some(notify) = state.gates.notifier(&gate_id) else {
|
||||
// Gate was released; send client back to the main menu.
|
||||
let base = &state.public_base_url;
|
||||
return text_plain(format!("#!ipxe\nchain {base}/boot.ipxe\n"));
|
||||
};
|
||||
|
||||
// Wait for an assignment or timeout.
|
||||
let _ = tokio::time::timeout(Duration::from_secs(25), notify.notified()).await;
|
||||
|
||||
let snap = state.gates.touch(&gate_id);
|
||||
let base = &state.public_base_url;
|
||||
match snap {
|
||||
// Bind `target` directly so we can't observe an Option::None between
|
||||
// the guard and the unwrap (the old code had a race with concurrent
|
||||
// `release`). We also do NOT release the gate here — the web UI
|
||||
// operator releases it explicitly, which keeps a record of "this
|
||||
// machine was assigned image X" visible until the client is known
|
||||
// to have started. Clients that retry on transient network errors
|
||||
// still get a valid boot script instead of falling back to the
|
||||
// menu.
|
||||
Some(g) if g.assigned_target.is_some() => {
|
||||
let target = g.assigned_target.clone().unwrap_or_default();
|
||||
tracing::info!(
|
||||
target: "pxeforge::gate",
|
||||
gate_id=%gate_id, mac=%g.mac, target=%target,
|
||||
"gate assignment delivered"
|
||||
);
|
||||
text_plain(format!(
|
||||
"#!ipxe\n\
|
||||
echo Gate assignment received: {target}\n\
|
||||
chain {base}/boot/{target}.ipxe || chain {base}/api/gate/poll/{gate_id}\n"
|
||||
))
|
||||
}
|
||||
Some(g) => {
|
||||
// No assignment yet - loop and re-poll. Repaint position so the
|
||||
// UI count stays accurate if other gates were released meanwhile.
|
||||
text_plain(format!(
|
||||
"#!ipxe\n\
|
||||
echo Gate Position {} - still waiting\n\
|
||||
chain {base}/api/gate/poll/{gate_id}\n",
|
||||
g.position
|
||||
))
|
||||
}
|
||||
None => text_plain(format!("#!ipxe\nchain {base}/boot.ipxe\n")),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GateAssignBody {
|
||||
/// Boot entry id (from `BootEntry::id`). Same one used in
|
||||
/// `/boot/<id>.ipxe`.
|
||||
target: String,
|
||||
/// Gate ids to assign. Empty = assign to all currently queued gates.
|
||||
gate_ids: Vec<String>,
|
||||
}
|
||||
|
||||
async fn api_gate_assign(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<GateAssignBody>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let ids = if body.gate_ids.is_empty() {
|
||||
state.gates.list().into_iter().map(|g| g.id).collect::<Vec<_>>()
|
||||
} else {
|
||||
body.gate_ids
|
||||
};
|
||||
// Guard: target must exist as a BootEntry id.
|
||||
let found = state.iso_store.list().into_iter().any(|i| {
|
||||
i.boot_entries.iter().any(|e| e.id == body.target)
|
||||
});
|
||||
if !found {
|
||||
return Json(json!({ "ok": false, "error": "unknown target" }));
|
||||
}
|
||||
let n = state.gates.assign(&ids, &body.target);
|
||||
Json(json!({ "ok": true, "assigned": n, "target": body.target }))
|
||||
}
|
||||
|
||||
async fn api_gate_release(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(gate_id): AxumPath<String>,
|
||||
) -> StatusCode {
|
||||
match state.gates.release(&gate_id) {
|
||||
Some(_) => StatusCode::NO_CONTENT,
|
||||
None => StatusCode::NOT_FOUND,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── NFS share API ─────────────────────────────────────────────────────────
|
||||
|
||||
async fn api_nfs_list(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
Json(json!({ "mounts": state.nfs.list() }))
|
||||
}
|
||||
|
||||
async fn api_nfs_add(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<NfsAddRequest>,
|
||||
) -> Response {
|
||||
match state.nfs.add(req).await {
|
||||
Ok(m) => (StatusCode::CREATED, Json(m)).into_response(),
|
||||
// Anything from the manager surfaces as a user-fixable validation
|
||||
// error — bad host, kernel without NFS support, missing
|
||||
// `mount.nfs`, dead server. We pass the message through verbatim
|
||||
// so the UI can show it to the operator.
|
||||
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn api_nfs_remove(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> Response {
|
||||
match state.nfs.remove(&id).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn api_nfs_scan(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> Response {
|
||||
match state.nfs.rescan(&id).await {
|
||||
Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(),
|
||||
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Network info API ──────────────────────────────────────────────────────
|
||||
|
||||
async fn api_network(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
Json(json!({
|
||||
"server_ip": state.public_base_url
|
||||
.strip_prefix("http://")
|
||||
.unwrap_or(&state.public_base_url),
|
||||
"nic_name": state.nic_name,
|
||||
"subnet_mask": state.subnet_mask,
|
||||
"gateway": state.gateway,
|
||||
"dns_server": state.settings.snapshot().dns_server,
|
||||
"public_base_url": state.public_base_url,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NetworkPut {
|
||||
/// Operators can set or clear an informational DNS hint. Server IP /
|
||||
/// NIC / mask / gateway are auto-detected and not editable from the
|
||||
/// UI — changing them in the wrong direction would silently break
|
||||
/// PXE for every client.
|
||||
dns_server: String,
|
||||
}
|
||||
|
||||
async fn api_network_put(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<NetworkPut>,
|
||||
) -> StatusCode {
|
||||
let mut s = state.settings.snapshot();
|
||||
s.dns_server = body.dns_server.trim().to_string();
|
||||
state.settings.replace(s);
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn range_full() {
|
||||
let (s, e, p) = parse_range(None, 1000);
|
||||
assert_eq!((s, e, p), (0, 999, false));
|
||||
}
|
||||
#[test]
|
||||
fn range_open_ended() {
|
||||
let h = HeaderValue::from_static("bytes=500-");
|
||||
let (s, e, p) = parse_range(Some(&h), 1000);
|
||||
assert_eq!((s, e, p), (500, 999, true));
|
||||
}
|
||||
#[test]
|
||||
fn range_suffix() {
|
||||
let h = HeaderValue::from_static("bytes=-100");
|
||||
let (s, e, p) = parse_range(Some(&h), 1000);
|
||||
assert_eq!((s, e, p), (900, 999, true));
|
||||
}
|
||||
#[test]
|
||||
fn range_explicit() {
|
||||
let h = HeaderValue::from_static("bytes=10-99");
|
||||
let (s, e, p) = parse_range(Some(&h), 1000);
|
||||
assert_eq!((s, e, p), (10, 99, true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
//! iPXE script generator.
|
||||
//!
|
||||
//! ## Menu hierarchy (per Phase 2 spec)
|
||||
//!
|
||||
//! ```text
|
||||
//! Top level:
|
||||
//! Default
|
||||
//! > Boot from Local HDD
|
||||
//! Installers
|
||||
//! > Linux Installers -> submenu of Linux ISOs
|
||||
//! > Windows Installers -> submenu of Windows ISOs (gated by Settings::windows_enabled)
|
||||
//! Tools
|
||||
//! > Utilities -> memtest, etc. (embedded assets only)
|
||||
//! > PXEForge Shell -> drop to iPXE shell with branded prompt
|
||||
//! > Network Card Info -> ifstat / config / route dump
|
||||
//! Gated Deployment -> join the gate queue
|
||||
//! ```
|
||||
//!
|
||||
//! ## iPXE is entirely backend — users do not see or write iPXE
|
||||
//!
|
||||
//! All user-facing knobs live in `Settings`. Script generation translates
|
||||
//! those knobs into iPXE primitives (chain, menu, item, choose, etc.).
|
||||
//! There is intentionally no UI path to upload a custom `.ipxe` script.
|
||||
|
||||
use pxeforge_core::{Settings, TimeoutAction};
|
||||
use pxeforge_iso_store::{BootEntry, BootKind, IsoMeta};
|
||||
use pxeforge_iso_store::introspect::DistroFamily;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
/// Top-level PXEForge boot menu. Serialized identically for BIOS and UEFI
|
||||
/// clients because iPXE normalises the menu primitives across firmwares.
|
||||
#[must_use]
|
||||
pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String {
|
||||
let mut s = String::new();
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let timeout_ms = settings.boot_menu_timeout_secs.saturating_mul(1000);
|
||||
let default_item = match settings.timeout_action {
|
||||
TimeoutAction::LocalHdd => "local",
|
||||
TimeoutAction::GatedDeployment => "gate",
|
||||
// Stay -> iPXE's `--timeout 0` is "no timeout". Pick any default
|
||||
// label; the client waits for keypress.
|
||||
TimeoutAction::Stay => "local",
|
||||
};
|
||||
|
||||
let _ = writeln!(s, "#!ipxe");
|
||||
let _ = writeln!(s, "# PXEForge top-level menu - auto-generated, do not edit");
|
||||
let _ = writeln!(s, "set base-url {base}");
|
||||
let _ = writeln!(s, "set esc:hex 1b");
|
||||
let _ = writeln!(s, "set cls ${{esc:string}}[2J");
|
||||
let _ = writeln!(s, ":menu");
|
||||
let _ = writeln!(s, "menu PXEForge - network boot menu");
|
||||
let _ = writeln!(s, "item --gap -- ------------------------- Default -------------------------");
|
||||
let _ = writeln!(s, "item local Boot from Local HDD");
|
||||
let _ = writeln!(s, "item --gap -- ----------------------- Installers -----------------------");
|
||||
if has_family(isos, is_linux_family) {
|
||||
let _ = writeln!(s, "item linux Linux Installers >");
|
||||
} else {
|
||||
let _ = writeln!(s, "item --gap -- (no Linux ISOs uploaded)");
|
||||
}
|
||||
if settings.windows_enabled && has_family(isos, is_windows_family) {
|
||||
let _ = writeln!(s, "item windows Windows Installers >");
|
||||
} else if settings.windows_enabled {
|
||||
let _ = writeln!(s, "item --gap -- (no Windows ISOs uploaded)");
|
||||
} else {
|
||||
let _ = writeln!(s, "item --gap -- (Windows support disabled in Settings)");
|
||||
}
|
||||
let _ = writeln!(s, "item --gap -- -------------------------- Tools --------------------------");
|
||||
let _ = writeln!(s, "item tools Tools >");
|
||||
let _ = writeln!(s, "item --gap -- ---------------------- Gated Deployment ---------------------");
|
||||
let _ = writeln!(s, "item gate Gated Deployment (join queue)");
|
||||
let _ = writeln!(s, "item --gap");
|
||||
let _ = writeln!(s, "item --key x exit Exit iPXE");
|
||||
|
||||
if matches!(settings.timeout_action, TimeoutAction::Stay) {
|
||||
let _ = writeln!(s, "choose --default {default_item} target || goto menu");
|
||||
} else {
|
||||
let _ = writeln!(s, "choose --default {default_item} --timeout {timeout_ms} target || goto menu");
|
||||
}
|
||||
|
||||
// iPXE's `||` is strict about what follows. Each test uses `goto menu`
|
||||
// as the fallthrough target so the parser never sees a bare `||` with
|
||||
// trailing whitespace — some iPXE builds reject that.
|
||||
let _ = writeln!(s, "iseq ${{target}} local && chain {base}/boot/_local.ipxe || goto menu");
|
||||
let _ = writeln!(s, "iseq ${{target}} linux && chain {base}/boot/_linux_menu.ipxe || goto menu");
|
||||
let _ = writeln!(s, "iseq ${{target}} windows && chain {base}/boot/_windows_menu.ipxe || goto menu");
|
||||
let _ = writeln!(s, "iseq ${{target}} tools && chain {base}/boot/_tools_menu.ipxe || goto menu");
|
||||
let _ = writeln!(s, "iseq ${{target}} gate && chain {base}/boot/_gate.ipxe || goto menu");
|
||||
let _ = writeln!(s, "iseq ${{target}} exit && exit || goto menu");
|
||||
let _ = writeln!(s, "goto menu");
|
||||
s
|
||||
}
|
||||
|
||||
/// Per-family submenu (Linux or Windows). Each item shows the ISO size
|
||||
/// in MiB, iVentoy-style (`[ 4376 MB] ubuntu-22.04-desktop-amd64`).
|
||||
#[must_use]
|
||||
pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) -> String {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let title = if is_windows { "Windows Installers" } else { "Linux Installers" };
|
||||
let label = if is_windows { "windows" } else { "linux" };
|
||||
let mut s = String::new();
|
||||
let _ = writeln!(s, "#!ipxe");
|
||||
let _ = writeln!(s, "set base-url {base}");
|
||||
let _ = writeln!(s, ":menu");
|
||||
let _ = writeln!(s, "menu PXEForge - {title}");
|
||||
let filter: fn(DistroFamily) -> bool =
|
||||
if is_windows { is_windows_family } else { is_linux_family };
|
||||
let mut count = 0;
|
||||
for iso in isos {
|
||||
if !filter(iso.introspection.family) { continue; }
|
||||
for entry in &iso.boot_entries {
|
||||
let size_label = fmt_size_mib(iso.size_bytes);
|
||||
let key = hotkey_for_index(count);
|
||||
let _ = writeln!(
|
||||
s, "item {}{} [{:>6}] {}",
|
||||
key,
|
||||
entry.id,
|
||||
size_label,
|
||||
escape_label(&entry.title),
|
||||
);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
let _ = writeln!(s, "item --gap -- (no {label} images uploaded yet)");
|
||||
}
|
||||
let _ = writeln!(s, "item --gap");
|
||||
let _ = writeln!(s, "item --key b back < Back to main menu");
|
||||
let _ = writeln!(s, "choose target || goto menu");
|
||||
let _ = writeln!(s, "iseq ${{target}} back && chain {base}/boot.ipxe || goto menu");
|
||||
let _ = writeln!(s, "chain {base}/boot/${{target}}.ipxe || goto menu");
|
||||
s
|
||||
}
|
||||
|
||||
/// Format a byte count as `NNNN MB` (iVentoy-style — MB not MiB, to match
|
||||
/// operator expectations from the original tool).
|
||||
fn fmt_size_mib(bytes: u64) -> String {
|
||||
let mib = bytes / (1024 * 1024);
|
||||
format!("{} MB", mib)
|
||||
}
|
||||
|
||||
/// Assign `--key N <id>` hotkeys 1..9, then nothing for positions >=9.
|
||||
/// iPXE's menu needs the --key prefix as a separate token before the id.
|
||||
fn hotkey_for_index(i: usize) -> String {
|
||||
if i < 9 {
|
||||
format!("--key {} ", i + 1)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Tools submenu — Utilities, Shell, NIC Info, Reboot, Exit to firmware.
|
||||
#[must_use]
|
||||
pub fn render_tools_menu(base_url: &str) -> String {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let mut s = String::new();
|
||||
let _ = writeln!(s, "#!ipxe");
|
||||
let _ = writeln!(s, "set base-url {base}");
|
||||
let _ = writeln!(s, ":menu");
|
||||
let _ = writeln!(s, "menu PXEForge - Tools");
|
||||
let _ = writeln!(s, "item --key u util Utilities (memtest, ...)");
|
||||
let _ = writeln!(s, "item --key s shell PXEForge Shell");
|
||||
let _ = writeln!(s, "item --key n nic Network Card Info");
|
||||
let _ = writeln!(s, "item --gap");
|
||||
let _ = writeln!(s, "item --key r reboot Reboot Computer");
|
||||
let _ = writeln!(s, "item --key e firmware Exit and continue BIOS boot");
|
||||
let _ = writeln!(s, "item --gap");
|
||||
let _ = writeln!(s, "item --key b back < Back to main menu");
|
||||
let _ = writeln!(s, "choose target || goto menu");
|
||||
let _ = writeln!(s, "iseq ${{target}} util && chain {base}/boot/_util.ipxe || goto menu");
|
||||
let _ = writeln!(s, "iseq ${{target}} shell && chain {base}/boot/_shell.ipxe || goto menu");
|
||||
let _ = writeln!(s, "iseq ${{target}} nic && chain {base}/boot/_nic.ipxe || goto menu");
|
||||
let _ = writeln!(s, "iseq ${{target}} reboot && reboot || goto menu");
|
||||
let _ = writeln!(s, "iseq ${{target}} firmware && exit 0 || goto menu");
|
||||
let _ = writeln!(s, "iseq ${{target}} back && chain {base}/boot.ipxe || goto menu");
|
||||
let _ = writeln!(s, "goto menu");
|
||||
s
|
||||
}
|
||||
|
||||
/// "Boot from Local HDD". On BIOS, we sanboot the first local drive; on
|
||||
/// UEFI we `exit` so the firmware moves to the next boot entry (normally
|
||||
/// the internal disk).
|
||||
#[must_use]
|
||||
pub fn render_local_hdd(base_url: &str) -> String {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let mut s = String::new();
|
||||
let _ = writeln!(s, "#!ipxe");
|
||||
let _ = writeln!(s, "# Boot from Local HDD - platform-sensitive");
|
||||
let _ = writeln!(s, "iseq ${{platform}} pcbios && sanboot --no-describe --drive 0x80 || ");
|
||||
let _ = writeln!(s, "# UEFI path: fall through to the firmware's next boot entry");
|
||||
let _ = writeln!(s, "exit 0");
|
||||
let _ = writeln!(s, "# If the above exit returns, loop back to the main menu");
|
||||
let _ = writeln!(s, "chain {base}/boot.ipxe");
|
||||
s
|
||||
}
|
||||
|
||||
/// Utilities submenu. For Phase 2 we bundle memtest86+ as an optional
|
||||
/// asset (if absent, the item is listed but errors gracefully). No third-
|
||||
/// party tools are fetched at runtime.
|
||||
#[must_use]
|
||||
pub fn render_util(base_url: &str) -> String {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let mut s = String::new();
|
||||
let _ = writeln!(s, "#!ipxe");
|
||||
let _ = writeln!(s, ":menu");
|
||||
let _ = writeln!(s, "menu PXEForge - Utilities");
|
||||
let _ = writeln!(s, "item memtest MemTest86+ (RAM diagnostic)");
|
||||
let _ = writeln!(s, "item --gap");
|
||||
let _ = writeln!(s, "item back < Back");
|
||||
let _ = writeln!(s, "choose target || goto menu");
|
||||
let _ = writeln!(s, "iseq ${{target}} memtest && chain {base}/ipxe/memtest.bin || ");
|
||||
let _ = writeln!(s, "iseq ${{target}} back && chain {base}/boot/_tools_menu.ipxe || ");
|
||||
let _ = writeln!(s, "goto menu");
|
||||
s
|
||||
}
|
||||
|
||||
/// "PXEForge Shell" — iPXE shell, branded.
|
||||
#[must_use]
|
||||
pub fn render_shell(base_url: &str) -> String {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let mut s = String::new();
|
||||
let _ = writeln!(s, "#!ipxe");
|
||||
let _ = writeln!(s, "echo ==========================================");
|
||||
let _ = writeln!(s, "echo PXEForge Shell");
|
||||
let _ = writeln!(s, "echo 'exit' returns to the main menu");
|
||||
let _ = writeln!(s, "echo ==========================================");
|
||||
let _ = writeln!(s, "shell");
|
||||
let _ = writeln!(s, "chain {base}/boot.ipxe");
|
||||
s
|
||||
}
|
||||
|
||||
/// "Network Card Info" — print ifstat + route + config.
|
||||
#[must_use]
|
||||
pub fn render_nic_info(base_url: &str) -> String {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let mut s = String::new();
|
||||
let _ = writeln!(s, "#!ipxe");
|
||||
let _ = writeln!(s, "echo ==========================================");
|
||||
let _ = writeln!(s, "echo Network Card Info");
|
||||
let _ = writeln!(s, "echo ==========================================");
|
||||
let _ = writeln!(s, "ifstat");
|
||||
let _ = writeln!(s, "echo");
|
||||
let _ = writeln!(s, "route");
|
||||
let _ = writeln!(s, "echo");
|
||||
let _ = writeln!(s, "echo 'Press any key to return to menu'");
|
||||
let _ = writeln!(s, "prompt --timeout 30000");
|
||||
let _ = writeln!(s, "chain {base}/boot.ipxe");
|
||||
s
|
||||
}
|
||||
|
||||
/// Gated Deployment entry point. Joins the queue, then enters a long-poll
|
||||
/// loop (iPXE repeats the chain on 3xx redirects / HTTP errors until a
|
||||
/// real script comes back).
|
||||
#[must_use]
|
||||
pub fn render_gate_entry(base_url: &str) -> String {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let mut s = String::new();
|
||||
let _ = writeln!(s, "#!ipxe");
|
||||
let _ = writeln!(s, "# Gated Deployment - join the queue and wait for operator");
|
||||
let _ = writeln!(s, "echo Joining gate queue...");
|
||||
// imgfetch writes the body to a file in iPXE's transient FS; we read
|
||||
// the gate id out of the Location-style header by asking the server
|
||||
// to put it in the response body as a single token.
|
||||
let _ = writeln!(s, "chain --replace {base}/api/gate/join?mac=${{mac}}");
|
||||
s
|
||||
}
|
||||
|
||||
/// Per-entry boot script (same as Phase 1, with extra_kernel_args appended).
|
||||
#[must_use]
|
||||
pub fn render_entry(entry: &BootEntry, settings: &Settings, base_url: &str) -> String {
|
||||
let mut s = String::new();
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let _ = writeln!(s, "#!ipxe");
|
||||
let _ = writeln!(s, "set base-url {base}");
|
||||
match &entry.kind {
|
||||
BootKind::LinuxKernel { kernel_url, initrd_urls, args } => {
|
||||
let mut cmdline = args.cmdline.replace("${base-url}", base);
|
||||
if !settings.extra_kernel_args.trim().is_empty() {
|
||||
cmdline.push(' ');
|
||||
cmdline.push_str(settings.extra_kernel_args.trim());
|
||||
}
|
||||
let _ = writeln!(s, "kernel {base}/{kernel_url} {cmdline}");
|
||||
for u in initrd_urls {
|
||||
let _ = writeln!(s, "initrd {base}/{u}");
|
||||
}
|
||||
let _ = writeln!(s, "boot || goto failed");
|
||||
}
|
||||
BootKind::Wimboot { wimboot_url, files } => {
|
||||
let _ = writeln!(s, "kernel {base}/{wimboot_url}");
|
||||
for (tag, url) in files {
|
||||
let _ = writeln!(s, "initrd --name {tag} {base}/{url} {tag}");
|
||||
}
|
||||
let _ = writeln!(s, "boot || goto failed");
|
||||
}
|
||||
BootKind::SanBootIso { iso_url } => {
|
||||
let _ = writeln!(s, "sanboot --no-describe {base}/{iso_url} || goto failed");
|
||||
}
|
||||
}
|
||||
let _ = writeln!(s, ":failed");
|
||||
let _ = writeln!(s, "echo Boot failed - returning to menu in 5s");
|
||||
let _ = writeln!(s, "sleep 5");
|
||||
let _ = writeln!(s, "chain {base}/boot.ipxe");
|
||||
s
|
||||
}
|
||||
|
||||
fn is_linux_family(f: DistroFamily) -> bool {
|
||||
matches!(
|
||||
f,
|
||||
DistroFamily::DebianUbuntu
|
||||
| DistroFamily::RhelFedora
|
||||
| DistroFamily::OpenSuse
|
||||
| DistroFamily::Arch
|
||||
| DistroFamily::Alpine
|
||||
| DistroFamily::Unknown
|
||||
)
|
||||
}
|
||||
|
||||
fn is_windows_family(f: DistroFamily) -> bool {
|
||||
matches!(f, DistroFamily::WindowsPe)
|
||||
}
|
||||
|
||||
fn has_family(isos: &[IsoMeta], pred: fn(DistroFamily) -> bool) -> bool {
|
||||
isos.iter().any(|i| pred(i.introspection.family))
|
||||
}
|
||||
|
||||
fn escape_label(s: &str) -> String {
|
||||
s.chars().map(|c| match c { '\n' | '\r' => ' ', c => c }).collect()
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Minimal read-only ISO9660 lookup. Given an uploaded ISO file and an
|
||||
//! in-ISO path (e.g. `/casper/vmlinuz`), locate the file and return a
|
||||
//! `(start_byte, length_bytes)` pair so the HTTP handler can stream just
|
||||
//! that range from the on-disk ISO without full extraction.
|
||||
//!
|
||||
//! We only implement what we need: the Primary Volume Descriptor and Rock
|
||||
//! Ridge / Joliet extensions are ignored. Paths are matched case-insensitive
|
||||
//! against plain ISO9660 filenames (uppercase, `;1` version suffix stripped).
|
||||
//! This is sufficient for the kernel/initrd and wimboot files we serve;
|
||||
//! if a requested path isn't found, the handler returns 404 and the user
|
||||
//! can still download the whole ISO via `/iso/<id>.iso`.
|
||||
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
|
||||
const SECTOR: u64 = 2048;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileLocation {
|
||||
pub offset: u64,
|
||||
pub length: u64,
|
||||
}
|
||||
|
||||
/// Look up `in_iso_path` (leading slash optional, case-insensitive) in the
|
||||
/// ISO at `iso_path`. Returns None on any parsing or IO failure.
|
||||
pub fn lookup(iso_path: &Path, in_iso_path: &str) -> Option<FileLocation> {
|
||||
let mut f = std::fs::File::open(iso_path).ok()?;
|
||||
let root = read_root_directory(&mut f)?;
|
||||
let components: Vec<&str> = in_iso_path
|
||||
.trim_start_matches('/')
|
||||
.split('/')
|
||||
.filter(|c| !c.is_empty())
|
||||
.collect();
|
||||
if components.is_empty() { return None; }
|
||||
walk(&mut f, root.offset, root.length, &components)
|
||||
}
|
||||
|
||||
fn read_root_directory(f: &mut std::fs::File) -> Option<FileLocation> {
|
||||
// Primary Volume Descriptor at LBA 16.
|
||||
let mut pvd = [0u8; 2048];
|
||||
f.seek(SeekFrom::Start(16 * SECTOR)).ok()?;
|
||||
f.read_exact(&mut pvd).ok()?;
|
||||
if pvd[0] != 0x01 || &pvd[1..6] != b"CD001" { return None; }
|
||||
// Root directory record is at offset 156, length 34.
|
||||
let rec = &pvd[156..156 + 34];
|
||||
let (offset, length) = parse_dir_record_ext(rec)?;
|
||||
Some(FileLocation { offset: offset * SECTOR, length })
|
||||
}
|
||||
|
||||
/// Walk components down the directory tree starting at `dir_offset`.
|
||||
fn walk(
|
||||
f: &mut std::fs::File,
|
||||
dir_offset: u64,
|
||||
dir_len: u64,
|
||||
components: &[&str],
|
||||
) -> Option<FileLocation> {
|
||||
let mut dir = vec![0u8; dir_len as usize];
|
||||
f.seek(SeekFrom::Start(dir_offset)).ok()?;
|
||||
f.read_exact(&mut dir).ok()?;
|
||||
|
||||
let target = components[0];
|
||||
let rest = &components[1..];
|
||||
let mut i = 0;
|
||||
while i < dir.len() {
|
||||
let len = dir[i] as usize;
|
||||
if len == 0 {
|
||||
// Padding to sector boundary.
|
||||
let next = (i / SECTOR as usize + 1) * SECTOR as usize;
|
||||
if next <= i { break; }
|
||||
i = next;
|
||||
continue;
|
||||
}
|
||||
if i + len > dir.len() { break; }
|
||||
let rec = &dir[i..i + len];
|
||||
let name = dir_record_name(rec);
|
||||
let is_dir = (rec.get(25).copied().unwrap_or(0) & 0x02) != 0;
|
||||
// Skip "." (0x00) and ".." (0x01) pseudo-entries.
|
||||
let is_pseudo = matches!(rec.get(32).copied(), Some(1)) && rec.get(33).copied() == Some(0x00)
|
||||
|| matches!(rec.get(32).copied(), Some(1)) && rec.get(33).copied() == Some(0x01);
|
||||
if !is_pseudo && name.eq_ignore_ascii_case(target) {
|
||||
let (child_off, child_len) = parse_dir_record_ext(rec)?;
|
||||
if rest.is_empty() && !is_dir {
|
||||
return Some(FileLocation { offset: child_off * SECTOR, length: child_len });
|
||||
} else if !rest.is_empty() && is_dir {
|
||||
return walk(f, child_off * SECTOR, child_len, rest);
|
||||
}
|
||||
}
|
||||
i += len;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract (extent LBA, data length in bytes) from a directory record.
|
||||
/// Layout per ISO9660: bytes 2..10 extent LBA (LE+BE duplicate), 10..18
|
||||
/// data length (LE+BE duplicate). We trust the little-endian copy.
|
||||
fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> {
|
||||
if rec.len() < 34 { return None; }
|
||||
let lba = u32::from_le_bytes(rec[2..6].try_into().ok()?) as u64;
|
||||
let len = u32::from_le_bytes(rec[10..14].try_into().ok()?) as u64;
|
||||
Some((lba, len))
|
||||
}
|
||||
|
||||
/// Extract the identifier from a directory record, stripping ISO9660's
|
||||
/// `;1` version suffix.
|
||||
fn dir_record_name(rec: &[u8]) -> String {
|
||||
let name_len = *rec.get(32).unwrap_or(&0) as usize;
|
||||
if name_len == 0 || rec.len() < 33 + name_len { return String::new(); }
|
||||
let raw = &rec[33..33 + name_len];
|
||||
let s = String::from_utf8_lossy(raw).to_string();
|
||||
// Strip `;N` version suffix.
|
||||
if let Some(i) = s.rfind(';') { s[..i].to_string() } else { s }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! HTTP server — single axum app that serves:
|
||||
//! - `/` the web UI (static assets from `pxeforge-webui`)
|
||||
//! - `/api/*` JSON API for the web UI
|
||||
//! - `/boot.ipxe` the generated top-level iPXE boot menu
|
||||
//! - `/boot/<entry>.ipxe` per-entry iPXE scripts (one per boot target)
|
||||
//! - `/ipxe/<file>` bundled iPXE binaries (for UEFI HTTP boot)
|
||||
//! - `/iso/<id>.iso` raw ISO file (with Range support)
|
||||
//! - `/iso/<id>/<path>` files inside the ISO (for wimboot WIM fetches
|
||||
//! and Linux kernel/initrd, without having to
|
||||
//! re-extract on every request)
|
||||
//!
|
||||
//! The `<id>/<path>` handler uses a read-only ISO9660 shim (see `iso_fs`)
|
||||
//! that lseeks into the ISO on disk — so we never keep extracted copies.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod app;
|
||||
pub mod ipxe_script;
|
||||
pub mod iso_fs;
|
||||
pub mod log_stream;
|
||||
pub mod state;
|
||||
pub mod terminal;
|
||||
|
||||
pub use app::build_router;
|
||||
pub use state::AppState;
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Server-Sent Events stream for the Terminal tab's live log pane.
|
||||
//!
|
||||
//! On connection we emit the recent ring buffer (so the UI doesn't open
|
||||
//! to a blank pane), then forward every new line from the broadcast
|
||||
//! channel. Slow clients that fall behind get a `lagged` event and
|
||||
//! resume — better than dropping the connection mid-tail.
|
||||
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
extract::State,
|
||||
response::sse::{Event, KeepAlive, Sse},
|
||||
Json,
|
||||
};
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use pxeforge_core::LogLine;
|
||||
use serde_json::json;
|
||||
use std::convert::Infallible;
|
||||
use std::time::Duration;
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
|
||||
/// SSE handler. Each `data:` payload is a JSON object matching `LogLine`.
|
||||
pub async fn stream(
|
||||
State(state): State<AppState>,
|
||||
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
||||
// 1. Snapshot the recent buffer first so a fresh UI sees context.
|
||||
let recent = state.log_bus.recent();
|
||||
let recent_stream = futures::stream::iter(
|
||||
recent
|
||||
.into_iter()
|
||||
.map(|l| Ok(Event::default().data(line_json(&l)))),
|
||||
);
|
||||
|
||||
// 2. Then live updates. BroadcastStream yields Result<T, Lagged>; on
|
||||
// a lagged client we send a synthetic event so the UI can flag it
|
||||
// rather than silently dropping data.
|
||||
let rx = state.log_bus.subscribe();
|
||||
let live = BroadcastStream::new(rx).map(|res| match res {
|
||||
Ok(line) => Ok(Event::default().data(line_json(&line))),
|
||||
Err(tokio_stream::wrappers::errors::BroadcastStreamRecvError::Lagged(n)) => Ok(Event::default()
|
||||
.event("lagged")
|
||||
.data(json!({ "skipped": n }).to_string())),
|
||||
});
|
||||
|
||||
Sse::new(recent_stream.chain(live))
|
||||
.keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
|
||||
}
|
||||
|
||||
/// Plain JSON snapshot of the recent buffer, for clients that prefer a
|
||||
/// pull-based fetch over an SSE subscription.
|
||||
pub async fn recent(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
Json(json!({ "lines": state.log_bus.recent() }))
|
||||
}
|
||||
|
||||
/// Drop the in-memory ring buffer. Live subscribers are unaffected (they
|
||||
/// keep streaming new lines as they arrive).
|
||||
pub async fn clear(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
state.log_bus.clear();
|
||||
state
|
||||
.log_bus
|
||||
.push("info", "pxeforge::terminal", "log buffer cleared by operator");
|
||||
Json(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
fn line_json(l: &LogLine) -> String {
|
||||
serde_json::to_string(l).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use pxeforge_core::{ClientRegistry, GateQueue, LogBus, SettingsStore};
|
||||
use pxeforge_iso_store::{IsoStore, NfsManager, SmbManager};
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub iso_store: IsoStore,
|
||||
pub clients: Arc<ClientRegistry>,
|
||||
pub settings: Arc<SettingsStore>,
|
||||
pub gates: Arc<GateQueue>,
|
||||
/// Optional SMB manager. Present when the binary was given a writable
|
||||
/// `smb_dir` at startup; `None` in pure-Linux-only deployments where
|
||||
/// Windows support is not wired in. Settings toggle drives start/stop.
|
||||
pub smb: Option<Arc<SmbManager>>,
|
||||
/// NFS share manager. Always present (mounting is opt-in by the
|
||||
/// operator from the Storage tab); `add()` requires `mount.nfs` to be
|
||||
/// available in the runtime image. Surfaces errors per-mount rather
|
||||
/// than failing the global state.
|
||||
pub nfs: NfsManager,
|
||||
/// Live log bus consumed by the Terminal tab via SSE. Operator-issued
|
||||
/// terminal commands also push synthetic lines onto it so the tail
|
||||
/// shows them inline.
|
||||
pub log_bus: Arc<LogBus>,
|
||||
/// Wall-clock instant the server bound — used for the uptime chip.
|
||||
pub started_at: OffsetDateTime,
|
||||
/// Base URL advertised to PXE clients (e.g. `http://10.0.0.5`). Used when
|
||||
/// rendering iPXE scripts so every URL resolves offline.
|
||||
pub public_base_url: String,
|
||||
/// Name of the network interface auto-detected at startup (e.g.
|
||||
/// `enp1s0`). Surfaced read-only on the Network tab. Empty if the
|
||||
/// interface couldn't be identified.
|
||||
pub nic_name: String,
|
||||
/// Subnet mask of the public interface in dotted-quad form.
|
||||
pub subnet_mask: String,
|
||||
/// Default gateway IPv4 address.
|
||||
pub gateway: String,
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
//! Operator terminal — typed commands over HTTP.
|
||||
//!
|
||||
//! The Terminal tab posts a single command line per request. We split it
|
||||
//! into argv, dispatch to a whitelisted handler, and return plain-text
|
||||
//! output. The handler also pushes the input line and any output onto
|
||||
//! the LogBus so commands and their results show up inline in the live
|
||||
//! tail (Minecraft-server-style).
|
||||
//!
|
||||
//! ## Why a whitelist
|
||||
//!
|
||||
//! Exposing a real shell would be a remote-code-execution endpoint. We
|
||||
//! keep the surface tiny and read-mostly; mutations are limited to the
|
||||
//! same operations the rest of the UI already exposes.
|
||||
|
||||
use crate::state::AppState;
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CommandRequest {
|
||||
/// Raw input as typed by the operator. Empty / all-whitespace is OK
|
||||
/// (returns the help banner).
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
pub async fn run_command(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<CommandRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let line = req.command.trim();
|
||||
if line.is_empty() {
|
||||
return (StatusCode::OK, Json(json!({ "output": HELP_TEXT, "ok": true })));
|
||||
}
|
||||
|
||||
// Echo the typed command into the live log so the Terminal tab shows
|
||||
// operator activity in-band with server-emitted log lines.
|
||||
state.log_bus.push("info", "pxeforge::terminal", format!("> {line}"));
|
||||
|
||||
let argv = shell_split(line);
|
||||
if argv.is_empty() {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(json!({ "output": HELP_TEXT, "ok": true })),
|
||||
);
|
||||
}
|
||||
|
||||
let result = dispatch(&state, &argv).await;
|
||||
let (ok, output) = match result {
|
||||
Ok(s) => (true, s),
|
||||
Err(s) => (false, s),
|
||||
};
|
||||
// Mirror command output to the log bus (truncated for noisy commands)
|
||||
// so reading the live tail tells the same story as scrolling the
|
||||
// terminal pane.
|
||||
let mirror = if output.len() > 1024 {
|
||||
format!("{}\n... ({} bytes truncated)", &output[..1024], output.len() - 1024)
|
||||
} else {
|
||||
output.clone()
|
||||
};
|
||||
if ok {
|
||||
state.log_bus.push("info", "pxeforge::terminal", mirror);
|
||||
} else {
|
||||
state.log_bus.push("warn", "pxeforge::terminal", mirror);
|
||||
}
|
||||
|
||||
(StatusCode::OK, Json(json!({ "output": output, "ok": ok })))
|
||||
}
|
||||
|
||||
async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
|
||||
let head = argv[0].as_str();
|
||||
let tail = &argv[1..];
|
||||
match head {
|
||||
"help" | "?" => Ok(HELP_TEXT.to_string()),
|
||||
"version" => Ok(format!("pxeforge {}", env!("CARGO_PKG_VERSION"))),
|
||||
"uptime" => Ok(uptime_string(state)),
|
||||
"status" => Ok(status_text(state)),
|
||||
"isos" | "images" => Ok(isos_text(state)),
|
||||
"clients" => Ok(clients_text(state)),
|
||||
"gate" => gate_command(state, tail).await,
|
||||
"nfs" => nfs_command(state, tail).await,
|
||||
"smb" => smb_command(state, tail).await,
|
||||
"log" => log_command(state, tail),
|
||||
"whoami" => Ok("operator".to_string()),
|
||||
"echo" => Ok(tail.join(" ")),
|
||||
"clear" => Ok("\x0c".to_string()), // form feed — frontend clears panel
|
||||
other => Err(format!(
|
||||
"unknown command: {other}\ntype 'help' for the list"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── status / lists ─────────────────────────────────────────────────────
|
||||
|
||||
fn status_text(s: &AppState) -> String {
|
||||
let isos = s.iso_store.list();
|
||||
let clients = s.clients.list();
|
||||
let gates = s.gates.list();
|
||||
let smb = s.smb.as_ref().map(|m| m.snapshot());
|
||||
let nfs = s.nfs.list();
|
||||
let nfs_active = nfs.iter().filter(|m| m.mounted).count();
|
||||
format!(
|
||||
"PXEForge {ver}\n\
|
||||
base url: {base}\n\
|
||||
interface: {nic}\n\
|
||||
uptime: {up}\n\
|
||||
isos: {n_isos} (local: {n_local}, nfs: {n_nfs})\n\
|
||||
clients: {n_clients}\n\
|
||||
gates: {n_gates}\n\
|
||||
smb: {smb}\n\
|
||||
nfs mounts: {n_total} configured ({n_active} active)\n",
|
||||
ver = env!("CARGO_PKG_VERSION"),
|
||||
base = s.public_base_url,
|
||||
nic = if s.nic_name.is_empty() { "?" } else { s.nic_name.as_str() },
|
||||
up = uptime_string(s),
|
||||
n_isos = isos.len(),
|
||||
n_local = isos.iter().filter(|i| matches!(i.source, pxeforge_iso_store::IsoSource::Local)).count(),
|
||||
n_nfs = isos.iter().filter(|i| !matches!(i.source, pxeforge_iso_store::IsoSource::Local)).count(),
|
||||
n_clients = clients.len(),
|
||||
n_gates = gates.len(),
|
||||
smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")),
|
||||
n_total = nfs.len(),
|
||||
n_active = nfs_active,
|
||||
)
|
||||
}
|
||||
|
||||
fn isos_text(s: &AppState) -> String {
|
||||
let isos = s.iso_store.list();
|
||||
if isos.is_empty() {
|
||||
return "(no isos)".into();
|
||||
}
|
||||
let mut out = String::new();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{:<32} {:<10} {:<10} {:<8}",
|
||||
"ID", "FAMILY", "SIZE", "SOURCE"
|
||||
);
|
||||
for i in isos {
|
||||
let src = match i.source {
|
||||
pxeforge_iso_store::IsoSource::Local => "local".to_string(),
|
||||
pxeforge_iso_store::IsoSource::Nfs { mount_id, .. } => format!("nfs:{mount_id}"),
|
||||
};
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{:<32} {:<10} {:<10} {:<8}",
|
||||
truncate(&i.id, 32),
|
||||
format!("{:?}", i.introspection.family),
|
||||
human_bytes(i.size_bytes),
|
||||
src,
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn clients_text(s: &AppState) -> String {
|
||||
let clients = s.clients.list();
|
||||
if clients.is_empty() {
|
||||
return "(no clients yet)".into();
|
||||
}
|
||||
let mut out = String::new();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{:<19} {:<16} {:<8} {}",
|
||||
"MAC", "IP", "EVENTS", "LAST SEEN"
|
||||
);
|
||||
for c in clients {
|
||||
let ip = c.last_ip.map_or_else(|| "-".into(), |i| i.to_string());
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{:<19} {:<16} {:<8} {}",
|
||||
c.mac,
|
||||
ip,
|
||||
c.events.len(),
|
||||
c.last_seen
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ── gate ───────────────────────────────────────────────────────────────
|
||||
|
||||
// `async` for symmetry with the other dispatch helpers — gate operations
|
||||
// are sync today but might grow to await on a database in a future phase.
|
||||
#[allow(clippy::unused_async)]
|
||||
async fn gate_command(s: &AppState, args: &[String]) -> Result<String, String> {
|
||||
match args.first().map(String::as_str) {
|
||||
None | Some("list") => {
|
||||
let gs = s.gates.list();
|
||||
if gs.is_empty() {
|
||||
return Ok("(no gates)".into());
|
||||
}
|
||||
let mut out = String::new();
|
||||
for g in gs {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"#{:<3} {:<19} {:<16} target={}",
|
||||
g.position,
|
||||
g.mac,
|
||||
g.id,
|
||||
g.assigned_target.unwrap_or_else(|| "-".into())
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
Some("assign-all") => {
|
||||
let target = args.get(1).ok_or_else(|| {
|
||||
"usage: gate assign-all <iso_boot_entry_id>".to_string()
|
||||
})?;
|
||||
let found = s
|
||||
.iso_store
|
||||
.list()
|
||||
.into_iter()
|
||||
.any(|i| i.boot_entries.iter().any(|e| &e.id == target));
|
||||
if !found {
|
||||
return Err(format!("no such boot entry: {target}"));
|
||||
}
|
||||
let ids: Vec<_> = s.gates.list().into_iter().map(|g| g.id).collect();
|
||||
let n = s.gates.assign(&ids, target);
|
||||
Ok(format!("assigned {n} gates -> {target}"))
|
||||
}
|
||||
Some("assign") => {
|
||||
let gate_id = args
|
||||
.get(1)
|
||||
.ok_or_else(|| "usage: gate assign <gate_id> <iso_boot_entry_id>".to_string())?;
|
||||
let target = args
|
||||
.get(2)
|
||||
.ok_or_else(|| "usage: gate assign <gate_id> <iso_boot_entry_id>".to_string())?;
|
||||
let n = s.gates.assign(std::slice::from_ref(gate_id), target);
|
||||
if n == 0 {
|
||||
return Err(format!("no such gate: {gate_id}"));
|
||||
}
|
||||
Ok(format!("assigned 1 gate -> {target}"))
|
||||
}
|
||||
Some("release") => {
|
||||
let gate_id = args.get(1).ok_or_else(|| "usage: gate release <gate_id>".to_string())?;
|
||||
match s.gates.release(gate_id) {
|
||||
Some(_) => Ok(format!("released {gate_id}")),
|
||||
None => Err(format!("no such gate: {gate_id}")),
|
||||
}
|
||||
}
|
||||
Some(other) => Err(format!(
|
||||
"unknown gate subcommand: {other}\ntry: gate [list|assign-all|assign|release]"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── nfs ────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
|
||||
match args.first().map(String::as_str) {
|
||||
None | Some("list") => {
|
||||
let mounts = s.nfs.list();
|
||||
if mounts.is_empty() {
|
||||
return Ok("(no NFS mounts configured)".into());
|
||||
}
|
||||
let mut out = String::new();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{:<24} {:<6} {:<7} {:<6} {}",
|
||||
"ID", "VER", "STATUS", "ISOS", "TARGET"
|
||||
);
|
||||
for m in mounts {
|
||||
let status = if m.mounted { "ok" } else { "down" };
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{:<24} {:<6} {:<7} {:<6} {}:{}",
|
||||
truncate(&m.id, 24),
|
||||
match m.version {
|
||||
pxeforge_iso_store::NfsVersion::V3 => "v3",
|
||||
pxeforge_iso_store::NfsVersion::V41 => "v4.1",
|
||||
},
|
||||
status,
|
||||
m.iso_count,
|
||||
m.server,
|
||||
m.export,
|
||||
);
|
||||
if let Some(e) = m.last_error {
|
||||
let _ = writeln!(out, " error: {e}");
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
Some("mount") => {
|
||||
// nfs mount <server>:<export> [v3|v41] [ro|rw]
|
||||
let target = args
|
||||
.get(1)
|
||||
.ok_or_else(|| "usage: nfs mount <server>:<export> [v3|v41] [ro|rw]".to_string())?;
|
||||
let (server, export) = target
|
||||
.split_once(':')
|
||||
.ok_or_else(|| "target must be 'server:/export'".to_string())?;
|
||||
let version = match args.get(2).map(String::as_str) {
|
||||
Some("v3") => pxeforge_iso_store::NfsVersion::V3,
|
||||
Some("v41") | None => pxeforge_iso_store::NfsVersion::V41,
|
||||
Some(other) => return Err(format!("unknown nfs version: {other} (expect v3 or v41)")),
|
||||
};
|
||||
let read_only = !matches!(args.get(3).map(String::as_str), Some("rw"));
|
||||
let req = pxeforge_iso_store::NfsAddRequest {
|
||||
server: server.to_string(),
|
||||
export: export.to_string(),
|
||||
version,
|
||||
read_only,
|
||||
};
|
||||
match s.nfs.add(req).await {
|
||||
Ok(m) => Ok(format!("mounted {} ({} isos)", m.id, m.iso_count)),
|
||||
Err(e) => Err(format!("mount failed: {e}")),
|
||||
}
|
||||
}
|
||||
Some("unmount") => {
|
||||
let id = args.get(1).ok_or_else(|| "usage: nfs unmount <id>".to_string())?;
|
||||
match s.nfs.remove(id).await {
|
||||
Ok(()) => Ok(format!("unmounted {id}")),
|
||||
Err(e) => Err(format!("unmount failed: {e}")),
|
||||
}
|
||||
}
|
||||
Some("scan") => {
|
||||
let id = args.get(1).ok_or_else(|| "usage: nfs scan <id>".to_string())?;
|
||||
match s.nfs.rescan(id).await {
|
||||
Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
|
||||
Err(e) => Err(format!("scan failed: {e}")),
|
||||
}
|
||||
}
|
||||
Some(other) => Err(format!(
|
||||
"unknown nfs subcommand: {other}\ntry: nfs [list|mount|unmount|scan]"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── smb ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[allow(clippy::unused_async)]
|
||||
async fn smb_command(s: &AppState, args: &[String]) -> Result<String, String> {
|
||||
let smb = s
|
||||
.smb
|
||||
.as_ref()
|
||||
.ok_or_else(|| "SMB manager not configured (Windows support disabled)".to_string())?;
|
||||
match args.first().map(String::as_str) {
|
||||
None | Some("status") => Ok(format!("{:#?}", smb.snapshot())),
|
||||
Some("start") => {
|
||||
// start/reconcile return the new SmbState — there's no
|
||||
// separate Result type. The state itself indicates success
|
||||
// or failure via its variant.
|
||||
let st = smb.start();
|
||||
Ok(format!("smbd start requested -> {st:?}"))
|
||||
}
|
||||
Some("stop") => {
|
||||
smb.stop();
|
||||
Ok("smbd stop requested".into())
|
||||
}
|
||||
Some("reload") => {
|
||||
let st = smb.reconcile();
|
||||
Ok(format!("smbd reload (SIGHUP) sent -> {st:?}"))
|
||||
}
|
||||
Some(other) => Err(format!(
|
||||
"unknown smb subcommand: {other}\ntry: smb [status|start|stop|reload]"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── log ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn log_command(s: &AppState, args: &[String]) -> Result<String, String> {
|
||||
match args.first().map(String::as_str) {
|
||||
Some("clear") => {
|
||||
s.log_bus.clear();
|
||||
Ok("log buffer cleared".into())
|
||||
}
|
||||
Some("tail") => {
|
||||
let n: usize = args
|
||||
.get(1)
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(20);
|
||||
let lines = s.log_bus.recent();
|
||||
let start = lines.len().saturating_sub(n);
|
||||
let mut out = String::new();
|
||||
for l in &lines[start..] {
|
||||
let _ = writeln!(out, "{}", l.render());
|
||||
}
|
||||
if out.is_empty() {
|
||||
Ok("(empty)".into())
|
||||
} else {
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
_ => Err("usage: log [clear|tail [n]]".into()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
fn uptime_string(s: &AppState) -> String {
|
||||
let now = time::OffsetDateTime::now_utc();
|
||||
let secs = (now - s.started_at).whole_seconds().max(0);
|
||||
let h = secs / 3600;
|
||||
let m = (secs % 3600) / 60;
|
||||
let s = secs % 60;
|
||||
format!("{h}h {m}m {s}s")
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}…", &s[..max.saturating_sub(1)])
|
||||
}
|
||||
}
|
||||
|
||||
fn human_bytes(n: u64) -> String {
|
||||
const U: &[&str] = &["B", "KB", "MB", "GB", "TB"];
|
||||
// Loss-of-precision past 2^52 is academic for ISO file sizes — even
|
||||
// a 4 PiB file still rounds to the right unit.
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let mut x = n as f64;
|
||||
let mut i = 0;
|
||||
while x >= 1024.0 && i < U.len() - 1 {
|
||||
x /= 1024.0;
|
||||
i += 1;
|
||||
}
|
||||
if i == 0 || x >= 10.0 {
|
||||
format!("{:.0} {}", x, U[i])
|
||||
} else {
|
||||
format!("{:.1} {}", x, U[i])
|
||||
}
|
||||
}
|
||||
|
||||
/// Tiny shell-like splitter — splits on whitespace, honoring `'…'` and
|
||||
/// `"…"` quoted segments. We deliberately don't expand `$VAR` or any
|
||||
/// other shell metacharacters; this is a parser for our own command
|
||||
/// vocabulary, not a real shell.
|
||||
pub fn shell_split(input: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut in_single = false;
|
||||
let mut in_double = false;
|
||||
for ch in input.chars() {
|
||||
match ch {
|
||||
'\'' if !in_double => in_single = !in_single,
|
||||
'"' if !in_single => in_double = !in_double,
|
||||
c if c.is_whitespace() && !in_single && !in_double => {
|
||||
if !current.is_empty() {
|
||||
out.push(std::mem::take(&mut current));
|
||||
}
|
||||
}
|
||||
c => current.push(c),
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
out.push(current);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
const HELP_TEXT: &str = "\
|
||||
PXEForge terminal — available commands:
|
||||
|
||||
help show this help
|
||||
version print server version
|
||||
status high-level server status
|
||||
uptime time since startup
|
||||
|
||||
isos list registered ISOs
|
||||
clients list PXE clients seen this session
|
||||
gate list list gated-deployment queue
|
||||
gate assign <gate_id> <target> assign one gate to a boot entry
|
||||
gate assign-all <target> assign every waiting gate
|
||||
gate release <gate_id> release one gate
|
||||
|
||||
nfs list list NFS mounts
|
||||
nfs mount <s>:<e> [v3|v41] [ro|rw] add and mount an NFS share
|
||||
nfs unmount <id> unmount and forget a share
|
||||
nfs scan <id> re-scan a share for new ISOs
|
||||
|
||||
smb status SMB (Samba) state
|
||||
smb start | stop | reload control smbd
|
||||
|
||||
log clear drop the in-memory log ring buffer
|
||||
log tail [n] show the last n buffered lines (default 20)
|
||||
clear clear the terminal pane
|
||||
|
||||
Tab to autocomplete is not implemented (sorry).\n";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shell_split_basic() {
|
||||
assert_eq!(shell_split(""), Vec::<String>::new());
|
||||
assert_eq!(shell_split("nfs list"), vec!["nfs", "list"]);
|
||||
assert_eq!(
|
||||
shell_split("nfs mount 10.0.0.5:/srv v41 ro"),
|
||||
vec!["nfs", "mount", "10.0.0.5:/srv", "v41", "ro"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_split_quoted() {
|
||||
assert_eq!(
|
||||
shell_split("echo 'hello world' done"),
|
||||
vec!["echo", "hello world", "done"]
|
||||
);
|
||||
assert_eq!(
|
||||
shell_split(r#"echo "double quotes" 'and singles'"#),
|
||||
vec!["echo", "double quotes", "and singles"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn human_bytes_units() {
|
||||
assert_eq!(human_bytes(0), "0 B");
|
||||
assert_eq!(human_bytes(1023), "1023 B");
|
||||
assert_eq!(human_bytes(1024), "1.0 KB");
|
||||
assert_eq!(human_bytes(2 * 1024 * 1024), "2.0 MB");
|
||||
assert_eq!(human_bytes(5 * 1024u64.pow(3)), "5.0 GB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_keeps_short() {
|
||||
assert_eq!(truncate("hi", 10), "hi");
|
||||
assert_eq!(truncate("longerthanfive", 5), "long…");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
//! End-to-end HTTP integration test.
|
||||
//!
|
||||
//! Spins up the real axum router against a temp ISO store + settings store,
|
||||
//! then walks an imaginary iPXE client through: dashboard status → upload
|
||||
//! ISO → fetch top-level boot menu → fetch per-entry script → Range-GET the
|
||||
//! ISO. Also drives the Gated Deployment flow end-to-end: two clients join,
|
||||
//! operator assigns, both polls return the chain script with retry fallback.
|
||||
//!
|
||||
//! This is the closest we can get to "real PXE client" without QEMU; the
|
||||
//! TFTP leg is separately unit-tested in `crates/tftp`. Between the two,
|
||||
//! every HTTP endpoint a real client touches is covered by a test.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, Request, StatusCode};
|
||||
use pxeforge_core::{ClientRegistry, GateQueue, LogBus, SettingsStore};
|
||||
use pxeforge_http_api::{build_router, AppState};
|
||||
use pxeforge_iso_store::{IsoStore, NfsManager};
|
||||
use tempfile::tempdir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// Build a tiny valid ISO9660 blob with volume label "ALPINE-TEST" so
|
||||
/// introspection identifies it as Alpine.
|
||||
fn fake_alpine_iso() -> Vec<u8> {
|
||||
let mut buf = vec![0u8; 32 * 2048];
|
||||
let off = 16 * 2048;
|
||||
buf[off] = 0x01;
|
||||
buf[off + 1..off + 6].copy_from_slice(b"CD001");
|
||||
buf[off + 6] = 0x01;
|
||||
let label = b"ALPINE-TEST".to_vec();
|
||||
let mut padded = label.clone();
|
||||
padded.resize(32, b' ');
|
||||
buf[off + 40..off + 40 + 32].copy_from_slice(&padded);
|
||||
let term = 17 * 2048;
|
||||
buf[term] = 0xFF;
|
||||
buf[term + 1..term + 6].copy_from_slice(b"CD001");
|
||||
buf[term + 6] = 0x01;
|
||||
buf
|
||||
}
|
||||
|
||||
fn multipart_iso_body(filename: &str, bytes: &[u8]) -> (String, Vec<u8>) {
|
||||
let boundary = "----PxeForgeTestBoundary1234";
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
||||
body.extend_from_slice(
|
||||
format!(
|
||||
"Content-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\n"
|
||||
).as_bytes(),
|
||||
);
|
||||
body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
|
||||
body.extend_from_slice(bytes);
|
||||
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
|
||||
let ct = format!("multipart/form-data; boundary={boundary}");
|
||||
(ct, body)
|
||||
}
|
||||
|
||||
async fn get(router: &axum::Router, path: &str) -> (StatusCode, Vec<u8>) {
|
||||
let res = router
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let status = res.status();
|
||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap().to_vec();
|
||||
(status, body)
|
||||
}
|
||||
|
||||
async fn post_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec<u8>) {
|
||||
let res = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(path)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.to_owned()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = res.status();
|
||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap().to_vec();
|
||||
(status, body)
|
||||
}
|
||||
|
||||
async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
let dir = tempdir().unwrap();
|
||||
let iso_store = IsoStore::new(dir.path().join("isos"));
|
||||
iso_store.ensure_dirs().await.unwrap();
|
||||
let clients = ClientRegistry::new();
|
||||
let gates = GateQueue::new();
|
||||
let settings = SettingsStore::load_or_default(dir.path());
|
||||
let nfs = NfsManager::new(dir.path(), iso_store.clone());
|
||||
iso_store.set_nfs_root(nfs.mount_root());
|
||||
let log_bus = LogBus::new(64);
|
||||
let state = AppState {
|
||||
iso_store,
|
||||
clients,
|
||||
gates,
|
||||
settings,
|
||||
smb: None,
|
||||
nfs,
|
||||
log_bus,
|
||||
started_at: time::OffsetDateTime::now_utc(),
|
||||
public_base_url: "http://127.0.0.1".into(),
|
||||
nic_name: "lo".into(),
|
||||
subnet_mask: "255.0.0.0".into(),
|
||||
gateway: "127.0.0.1".into(),
|
||||
};
|
||||
(state, dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_and_ready_endpoints() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (s, b) = get(&app, "/healthz").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(b, b"ok\n");
|
||||
// /readyz should 503 when no iPXE binaries bundled at test time — this
|
||||
// actually depends on whether CI has fetched them. Accept either.
|
||||
let (s2, _) = get(&app, "/readyz").await;
|
||||
assert!(
|
||||
s2 == StatusCode::OK || s2 == StatusCode::SERVICE_UNAVAILABLE,
|
||||
"unexpected readyz status: {s2}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_introspects_and_generates_boot_entry() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state.clone());
|
||||
|
||||
let iso = fake_alpine_iso();
|
||||
let (ct, body) = multipart_iso_body("fake-alpine.iso", &iso);
|
||||
let res = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/isos")
|
||||
.header("content-type", ct)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::CREATED, "upload failed");
|
||||
|
||||
// Confirm the ISO shows up in the menu.
|
||||
let (_, menu) = get(&app, "/boot.ipxe").await;
|
||||
let menu = String::from_utf8(menu).unwrap();
|
||||
assert!(menu.contains("Linux Installers"), "menu missing Linux submenu:\n{menu}");
|
||||
|
||||
let (_, linux) = get(&app, "/boot/_linux_menu.ipxe").await;
|
||||
let linux = String::from_utf8(linux).unwrap();
|
||||
assert!(linux.contains("fake-alpine-linux"), "linux submenu missing entry:\n{linux}");
|
||||
assert!(linux.contains("[ 0 MB]") || linux.contains("[ 0 MB]"),
|
||||
"size label missing in {linux}");
|
||||
|
||||
// Per-entry boot script should include kernel + initrd URLs + boot.
|
||||
let (_, entry) = get(&app, "/boot/fake-alpine-linux.ipxe").await;
|
||||
let entry = String::from_utf8(entry).unwrap();
|
||||
assert!(entry.contains("kernel http://127.0.0.1/iso/fake-alpine/boot/vmlinuz-lts"));
|
||||
assert!(entry.contains("initrd http://127.0.0.1/iso/fake-alpine/boot/initramfs-lts"));
|
||||
assert!(entry.contains("boot || goto failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn iso_range_request_slices_correctly() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state.clone());
|
||||
let iso = fake_alpine_iso();
|
||||
let (ct, body) = multipart_iso_body("fake-alpine.iso", &iso);
|
||||
app.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST").uri("/api/isos")
|
||||
.header("content-type", ct)
|
||||
.body(Body::from(body)).unwrap()).await.unwrap();
|
||||
|
||||
// Range bytes=0x8000-0x8005 should return the PVD signature byte.
|
||||
let res = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/iso/fake-alpine.iso")
|
||||
.header(header::RANGE, "bytes=32768-32773")
|
||||
.body(Body::empty()).unwrap())
|
||||
.await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
|
||||
let slice = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(slice[0], 0x01); // PVD type
|
||||
assert_eq!(&slice[1..6], b"CD001");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gated_deployment_full_flow() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state.clone());
|
||||
|
||||
// Upload an ISO so the target exists.
|
||||
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
|
||||
app.clone()
|
||||
.oneshot(Request::builder().method("POST").uri("/api/isos")
|
||||
.header("content-type", ct).body(Body::from(body)).unwrap())
|
||||
.await.unwrap();
|
||||
|
||||
// Two clients join.
|
||||
let (_, join1) = get(&app, "/api/gate/join?mac=aa:bb:cc:00:00:01").await;
|
||||
let (_, join2) = get(&app, "/api/gate/join?mac=aa:bb:cc:00:00:02").await;
|
||||
let s1 = String::from_utf8(join1).unwrap();
|
||||
let s2 = String::from_utf8(join2).unwrap();
|
||||
assert!(s1.contains("Gate Position 1"));
|
||||
assert!(s2.contains("Gate Position 2"));
|
||||
|
||||
let gate1_id = s1.lines().find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/gate/poll/"))
|
||||
.unwrap().to_string();
|
||||
let gate2_id = s2.lines().find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/gate/poll/"))
|
||||
.unwrap().to_string();
|
||||
|
||||
// Kick off a long-poll for client 1 in the background. Then assign.
|
||||
let app2 = app.clone();
|
||||
let poll_future = tokio::spawn(async move {
|
||||
let uri = format!("/api/gate/poll/{gate1_id}");
|
||||
get(&app2, &uri).await
|
||||
});
|
||||
|
||||
// Give the poll a moment to register its notify subscription.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
|
||||
// Operator assigns.
|
||||
let body = format!(r#"{{"target":"fake-alpine-linux","gate_ids":["{gate2_id}"]}}"#);
|
||||
let (s, b) = post_json(&app, "/api/gate/assign", &body).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let assign_json = String::from_utf8(b).unwrap();
|
||||
assert!(assign_json.contains(r#""assigned":1"#), "assign response: {assign_json}");
|
||||
|
||||
// Now assign to gate 1 too so the background poll wakes.
|
||||
let body = format!(r#"{{"target":"fake-alpine-linux","gate_ids":[]}}"#);
|
||||
post_json(&app, "/api/gate/assign", &body).await;
|
||||
|
||||
let (poll_status, poll_body) = poll_future.await.unwrap();
|
||||
assert_eq!(poll_status, StatusCode::OK);
|
||||
let poll_s = String::from_utf8(poll_body).unwrap();
|
||||
assert!(
|
||||
poll_s.contains("chain http://127.0.0.1/boot/fake-alpine-linux.ipxe"),
|
||||
"poll response should chain the boot script:\n{poll_s}"
|
||||
);
|
||||
// Retry-on-error fallback must be present.
|
||||
assert!(poll_s.contains("|| chain http://127.0.0.1/api/gate/poll/"),
|
||||
"retry fallback missing");
|
||||
|
||||
// Bad target must be rejected.
|
||||
let (_, bad) = post_json(&app, "/api/gate/assign",
|
||||
r#"{"target":"does-not-exist","gate_ids":[]}"#).await;
|
||||
let bad_s = String::from_utf8(bad).unwrap();
|
||||
assert!(bad_s.contains(r#""ok":false"#), "expected rejection: {bad_s}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settings_put_persists_across_reads() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let body = serde_json::json!({
|
||||
"boot_menu_timeout_secs": 42,
|
||||
"timeout_action": "local_hdd",
|
||||
"windows_enabled": false,
|
||||
"smb_host_override": "",
|
||||
"extra_kernel_args": "console=ttyS0",
|
||||
"default_local_hdd": true,
|
||||
"gate_wait_max_secs": 0
|
||||
}).to_string();
|
||||
|
||||
let res = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/settings")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body))
|
||||
.unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let (_, g) = get(&app, "/api/settings").await;
|
||||
let got: serde_json::Value = serde_json::from_slice(&g).unwrap();
|
||||
assert_eq!(got["boot_menu_timeout_secs"], 42);
|
||||
assert_eq!(got["timeout_action"], "local_hdd");
|
||||
assert_eq!(got["extra_kernel_args"], "console=ttyS0");
|
||||
|
||||
// And the menu should now use the new timeout.
|
||||
let (_, menu) = get(&app, "/boot.ipxe").await;
|
||||
let menu = String::from_utf8(menu).unwrap();
|
||||
assert!(menu.contains("--timeout 42000"),
|
||||
"menu should reflect 42s timeout:\n{menu}");
|
||||
assert!(menu.contains("--default local"),
|
||||
"menu should default to local:\n{menu}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reboot_and_firmware_exit_in_tools_menu() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (_, tools) = get(&app, "/boot/_tools_menu.ipxe").await;
|
||||
let tools = String::from_utf8(tools).unwrap();
|
||||
assert!(tools.contains("Reboot Computer"),
|
||||
"tools menu missing Reboot item:\n{tools}");
|
||||
assert!(tools.contains("Exit and continue BIOS boot"),
|
||||
"tools menu missing firmware-exit item:\n{tools}");
|
||||
assert!(tools.contains("&& reboot"),
|
||||
"reboot command not wired:\n{tools}");
|
||||
assert!(tools.contains("&& exit 0"),
|
||||
"firmware exit command not wired:\n{tools}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ui_assets_served_offline() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
|
||||
for (path, ct) in [
|
||||
("/", "text/html"),
|
||||
("/assets/app.js", "application/javascript"),
|
||||
("/assets/app.css", "text/css"),
|
||||
("/assets/logo.svg", "image/svg+xml"),
|
||||
] {
|
||||
let res = app
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
|
||||
.await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK, "{path} not 200");
|
||||
let got = res.headers()
|
||||
.get(header::CONTENT_TYPE).unwrap()
|
||||
.to_str().unwrap();
|
||||
assert!(got.starts_with(ct), "{path} ct={got}, expected {ct}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_external_urls_in_generated_ipxe() {
|
||||
// Sanity check that nothing we serve points off-server.
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
for path in ["/boot.ipxe", "/boot/_tools_menu.ipxe", "/boot/_linux_menu.ipxe",
|
||||
"/boot/_shell.ipxe", "/boot/_nic.ipxe", "/boot/_local.ipxe"] {
|
||||
let (_, body) = get(&app, path).await;
|
||||
let s = String::from_utf8(body).unwrap();
|
||||
// The only URLs we should emit are relative to our own public_base_url.
|
||||
for url in ["github.com", "googleapis", "cdn.", "cdnjs", "unpkg", "jsdelivr"] {
|
||||
assert!(!s.contains(url), "{path} references external host {url}:\n{s}");
|
||||
}
|
||||
// Confirm URLs are all ours.
|
||||
for line in s.lines() {
|
||||
if let Some(idx) = line.find("http://") {
|
||||
let rest = &line[idx..];
|
||||
assert!(rest.starts_with("http://127.0.0.1"),
|
||||
"{path} references non-public-base URL: {line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 4 integration tests ────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn nfs_add_with_bad_export_is_rejected() {
|
||||
// Validation must happen before we shell out to /bin/mount —
|
||||
// otherwise the operator sees opaque kernel errors instead of a
|
||||
// clear "your export must start with /" hint.
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (s, b) = post_json(
|
||||
&app,
|
||||
"/api/nfs",
|
||||
r#"{"server":"10.0.0.5","export":"isos","version":"v41","read_only":true}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
let msg = String::from_utf8_lossy(&b);
|
||||
assert!(msg.contains("export"), "expected validation hint, got: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nfs_list_starts_empty() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (s, b) = get(&app, "/api/nfs").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||||
assert_eq!(v["mounts"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_help_and_status_round_trip() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
|
||||
// Empty command -> help banner.
|
||||
let (s, b) = post_json(&app, "/api/terminal", r#"{"command":""}"#).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||||
assert!(v["output"].as_str().unwrap().contains("PXEForge terminal"));
|
||||
|
||||
// status -> contains the version banner.
|
||||
let (s, b) = post_json(&app, "/api/terminal", r#"{"command":"status"}"#).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||||
let out = v["output"].as_str().unwrap();
|
||||
assert!(out.starts_with("PXEForge"), "unexpected status output: {out}");
|
||||
assert!(out.contains("isos:"), "status missing iso line: {out}");
|
||||
|
||||
// Unknown command -> ok=false plus help hint.
|
||||
let (_, b) = post_json(&app, "/api/terminal", r#"{"command":"frobnicate"}"#).await;
|
||||
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||||
assert_eq!(v["ok"], false);
|
||||
assert!(v["output"].as_str().unwrap().contains("unknown command"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn log_recent_returns_buffered_lines() {
|
||||
// The terminal command we issued seeds the log bus, so a follow-up
|
||||
// /api/log/recent must surface those lines as JSON.
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let _ = post_json(&app, "/api/terminal", r#"{"command":"version"}"#).await;
|
||||
let (s, b) = get(&app, "/api/log/recent").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||||
let lines = v["lines"].as_array().expect("lines array");
|
||||
assert!(!lines.is_empty(), "log buffer should have at least one line");
|
||||
// Every entry should have the canonical timestamp/level/target/message.
|
||||
for l in lines {
|
||||
for k in ["timestamp", "level", "target", "message"] {
|
||||
assert!(l.get(k).is_some(), "missing field {k} in log line: {l}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn network_endpoint_exposes_dns_round_trip() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
|
||||
// GET starts blank.
|
||||
let (_, b) = get(&app, "/api/network").await;
|
||||
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||||
assert_eq!(v["dns_server"], "");
|
||||
assert_eq!(v["nic_name"], "lo");
|
||||
|
||||
// PUT updates only the DNS field.
|
||||
let res = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/network")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"dns_server":"10.0.0.1"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let (_, b) = get(&app, "/api/network").await;
|
||||
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||||
assert_eq!(v["dns_server"], "10.0.0.1");
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "pxeforge-ipxe-assets"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Bundled iPXE binaries and default chain scripts for PXEForge"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
pxeforge-core.workspace = true
|
||||
rust-embed.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Bundled iPXE boot binaries and default chain script.
|
||||
//!
|
||||
//! At build time, we expect the iPXE binaries to live at `assets/ipxe/` at
|
||||
//! the workspace root. They are embedded into the PXEForge binary via
|
||||
//! `rust-embed` so the container image is self-contained. If a binary is
|
||||
//! missing, that architecture simply won't have PXE support — we log at
|
||||
//! startup and serve what we have.
|
||||
//!
|
||||
//! Filename convention (matches `ClientArch::ipxe_bootfile`):
|
||||
//! - `undionly.kpxe` — Legacy x86 BIOS
|
||||
//! - `snponly-i386.efi` — IA32 UEFI
|
||||
//! - `snponly.efi` — x86_64 UEFI
|
||||
//! - `snponly-arm32.efi` — ARM32 UEFI
|
||||
//! - `snponly-arm64.efi` — ARM64 UEFI
|
||||
//! - `ipxe.efi` (fallback) — UEFI with bundled drivers, if snponly fails on a NIC
|
||||
//! - `wimboot` — Windows boot shim (fetched separately for WIM chains)
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use pxeforge_core::ClientArch;
|
||||
use rust_embed::Embed;
|
||||
|
||||
#[derive(Embed)]
|
||||
#[folder = "../../assets/ipxe/"]
|
||||
#[include = "*.kpxe"]
|
||||
#[include = "*.efi"]
|
||||
#[include = "wimboot"]
|
||||
pub struct IpxeAssets;
|
||||
|
||||
/// Return the embedded iPXE binary for `arch`, or `None` if we didn't bundle
|
||||
/// one for that architecture.
|
||||
#[must_use]
|
||||
pub fn bootfile_bytes(arch: ClientArch) -> Option<Vec<u8>> {
|
||||
let name = arch.ipxe_bootfile()?;
|
||||
IpxeAssets::get(name).map(|f| f.data.into_owned())
|
||||
}
|
||||
|
||||
/// Return a named asset directly (e.g. `wimboot`, or a fallback `ipxe.efi`).
|
||||
#[must_use]
|
||||
pub fn asset_bytes(name: &str) -> Option<Vec<u8>> {
|
||||
IpxeAssets::get(name).map(|f| f.data.into_owned())
|
||||
}
|
||||
|
||||
/// Enumerate embedded asset filenames. Useful for startup logging so the
|
||||
/// operator can immediately tell which architectures will work.
|
||||
pub fn list_assets() -> Vec<String> {
|
||||
IpxeAssets::iter().map(|c| c.into_owned()).collect()
|
||||
}
|
||||
|
||||
/// Log at startup which iPXE binaries are present and which are missing.
|
||||
pub fn log_availability() {
|
||||
let have: std::collections::HashSet<String> = list_assets().into_iter().collect();
|
||||
let needed = [
|
||||
(ClientArch::LegacyX86, "undionly.kpxe"),
|
||||
(ClientArch::Ia32Uefi, "snponly-i386.efi"),
|
||||
(ClientArch::X64Uefi, "snponly.efi"),
|
||||
// ARM32 UEFI deferred — no upstream snponly binary published.
|
||||
(ClientArch::Arm64Uefi, "snponly-arm64.efi"),
|
||||
];
|
||||
for (arch, name) in needed {
|
||||
if have.contains(name) {
|
||||
tracing::info!(target: "pxeforge::ipxe", "bundled iPXE for {}: {}", arch.as_str(), name);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
target: "pxeforge::ipxe",
|
||||
"MISSING iPXE binary for {}: {} — clients of this arch will not PXE boot",
|
||||
arch.as_str(), name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "pxeforge-iso-store"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "ISO upload, storage, introspection, and boot-entry generation for PXEForge"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
pxeforge-core.workspace = true
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
sha2.workspace = true
|
||||
hex.workspace = true
|
||||
uuid.workspace = true
|
||||
time.workspace = true
|
||||
parking_lot.workspace = true
|
||||
bytes.workspace = true
|
||||
tempfile = "3.12"
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.12"
|
||||
@@ -0,0 +1,39 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BootEntry {
|
||||
/// Stable id (also the URL slug in generated iPXE scripts).
|
||||
pub id: String,
|
||||
/// Display label shown in the iPXE boot menu.
|
||||
pub title: String,
|
||||
pub kind: BootKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum BootKind {
|
||||
/// Linux kernel + initrd chainload. Kernel args carry the distro-specific
|
||||
/// pointer back to the ISO contents served over HTTP.
|
||||
LinuxKernel {
|
||||
kernel_url: String,
|
||||
initrd_urls: Vec<String>,
|
||||
args: KernelArgs,
|
||||
},
|
||||
/// Windows WinPE boot via wimboot shim. `files` maps in-memory tags to
|
||||
/// HTTP URLs the client fetches. See https://ipxe.org/wimboot .
|
||||
Wimboot {
|
||||
wimboot_url: String,
|
||||
files: Vec<(String, String)>,
|
||||
},
|
||||
/// Last-resort: SAN-boot the ISO as an emulated CD. Only works for small
|
||||
/// ISOs (<~1 GiB) and older distros. Kept for completeness, not the
|
||||
/// default.
|
||||
SanBootIso { iso_url: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct KernelArgs {
|
||||
/// Raw kernel command line, already distro-adapted. Do not quote — iPXE
|
||||
/// takes a single space-separated command line.
|
||||
pub cmdline: String,
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! ISO introspection — identify the distro family and locate kernel/initrd.
|
||||
//!
|
||||
//! We avoid a full ISO9660/Joliet/Rock-Ridge parser by reading a small number
|
||||
//! of well-known files via `isoinfo` (from cdrtools/genisoimage) when it's on
|
||||
//! the path. As a pure-Rust fallback we do a crude scan: read the volume
|
||||
//! descriptor at offset 0x8000 to grab the volume label, and grep for known
|
||||
//! filenames by scanning raw sectors — good enough to tell Debian from RHEL
|
||||
//! most of the time, without shelling out.
|
||||
//!
|
||||
//! The returned `IntrospectionReport` is what `BootEntry`s get generated from.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DistroFamily {
|
||||
DebianUbuntu,
|
||||
RhelFedora,
|
||||
OpenSuse,
|
||||
Arch,
|
||||
Alpine,
|
||||
WindowsPe,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IntrospectionReport {
|
||||
pub family: DistroFamily,
|
||||
pub volume_label: Option<String>,
|
||||
/// Kernel path inside the ISO (e.g. `/casper/vmlinuz`, `/isolinux/vmlinuz`).
|
||||
pub kernel_path: Option<String>,
|
||||
/// Initrd path(s) inside the ISO. May be multiple for multi-initrd setups.
|
||||
pub initrd_paths: Vec<String>,
|
||||
/// True if `sources/boot.wim` present — Windows install media.
|
||||
pub has_boot_wim: bool,
|
||||
}
|
||||
|
||||
/// Probe an ISO file on disk. Never fails — on unrecoverable IO error we log
|
||||
/// and return an `Unknown` family so the uploader still sees a record.
|
||||
pub fn introspect(path: &Path) -> IntrospectionReport {
|
||||
let mut report = IntrospectionReport {
|
||||
family: DistroFamily::Unknown,
|
||||
volume_label: None,
|
||||
kernel_path: None,
|
||||
initrd_paths: Vec::new(),
|
||||
has_boot_wim: false,
|
||||
};
|
||||
|
||||
let Ok(mut f) = std::fs::File::open(path) else {
|
||||
tracing::warn!(target: "pxeforge::iso", "cannot open ISO for introspection: {}", path.display());
|
||||
return report;
|
||||
};
|
||||
|
||||
// ISO9660 Primary Volume Descriptor at LBA 16 (offset 0x8000), 2048 bytes.
|
||||
// Bytes 40..72 are the Volume Identifier (space-padded, d-characters).
|
||||
let mut pvd = [0u8; 2048];
|
||||
if f.seek(SeekFrom::Start(0x8000)).is_ok() && f.read_exact(&mut pvd).is_ok() {
|
||||
// Byte 0 must be 0x01 (primary descriptor), bytes 1..6 = "CD001".
|
||||
if pvd[0] == 0x01 && &pvd[1..6] == b"CD001" {
|
||||
let label_raw = &pvd[40..72];
|
||||
let label = String::from_utf8_lossy(label_raw).trim().to_string();
|
||||
if !label.is_empty() {
|
||||
report.volume_label = Some(label.clone());
|
||||
report.family = family_from_label(&label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cheap content scan: read the first ~64 MiB, look for signature filenames.
|
||||
// This is enough to identify `sources/boot.wim` (Windows) and common
|
||||
// kernel/initrd paths for the major Linux distros.
|
||||
let _ = f.seek(SeekFrom::Start(0));
|
||||
let scan_bytes = 64 * 1024 * 1024;
|
||||
let mut buf = vec![0u8; 1024 * 1024];
|
||||
let mut read_total = 0usize;
|
||||
let mut haystack = Vec::with_capacity(scan_bytes.min(32 * 1024 * 1024));
|
||||
while read_total < scan_bytes {
|
||||
let n = f.read(&mut buf).unwrap_or(0);
|
||||
if n == 0 { break; }
|
||||
haystack.extend_from_slice(&buf[..n]);
|
||||
read_total += n;
|
||||
}
|
||||
|
||||
if contains_ascii(&haystack, b"sources/boot.wim")
|
||||
|| contains_ascii(&haystack, b"SOURCES/BOOT.WIM")
|
||||
|| contains_ascii(&haystack, b"SOURCES\\BOOT.WIM")
|
||||
{
|
||||
report.has_boot_wim = true;
|
||||
if report.family == DistroFamily::Unknown {
|
||||
report.family = DistroFamily::WindowsPe;
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort kernel/initrd path guess from family. These paths are what
|
||||
// distro ISOs conventionally ship at — we don't verify extraction here;
|
||||
// that happens in the store after introspection.
|
||||
let (k, i) = guess_kernel_initrd(report.family);
|
||||
report.kernel_path = k.map(str::to_string);
|
||||
report.initrd_paths = i.iter().map(|s| s.to_string()).collect();
|
||||
|
||||
report
|
||||
}
|
||||
|
||||
fn family_from_label(label: &str) -> DistroFamily {
|
||||
let l = label.to_ascii_lowercase();
|
||||
if l.contains("ubuntu") || l.contains("debian") || l.contains("mint") {
|
||||
DistroFamily::DebianUbuntu
|
||||
} else if l.contains("rhel") || l.contains("centos") || l.contains("fedora")
|
||||
|| l.contains("rocky") || l.contains("alma")
|
||||
{
|
||||
DistroFamily::RhelFedora
|
||||
} else if l.contains("suse") || l.contains("opensuse") {
|
||||
DistroFamily::OpenSuse
|
||||
} else if l.contains("arch") {
|
||||
DistroFamily::Arch
|
||||
} else if l.contains("alpine") {
|
||||
DistroFamily::Alpine
|
||||
} else if l.contains("windows") || l.contains("winpe") {
|
||||
DistroFamily::WindowsPe
|
||||
} else {
|
||||
DistroFamily::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
fn guess_kernel_initrd(family: DistroFamily) -> (Option<&'static str>, Vec<&'static str>) {
|
||||
match family {
|
||||
DistroFamily::DebianUbuntu => (Some("/casper/vmlinuz"), vec!["/casper/initrd"]),
|
||||
DistroFamily::RhelFedora => (Some("/images/pxeboot/vmlinuz"), vec!["/images/pxeboot/initrd.img"]),
|
||||
DistroFamily::OpenSuse => (Some("/boot/x86_64/loader/linux"), vec!["/boot/x86_64/loader/initrd"]),
|
||||
DistroFamily::Arch => (Some("/arch/boot/x86_64/vmlinuz-linux"), vec!["/arch/boot/x86_64/initramfs-linux.img"]),
|
||||
DistroFamily::Alpine => (Some("/boot/vmlinuz-lts"), vec!["/boot/initramfs-lts"]),
|
||||
DistroFamily::WindowsPe | DistroFamily::Unknown => (None, Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn contains_ascii(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
if needle.is_empty() || haystack.len() < needle.len() { return false; }
|
||||
haystack.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn label_matching() {
|
||||
assert_eq!(family_from_label("Ubuntu 24.04"), DistroFamily::DebianUbuntu);
|
||||
assert_eq!(family_from_label("Rocky-9-x86_64-dvd"), DistroFamily::RhelFedora);
|
||||
assert_eq!(family_from_label("openSUSE-Leap-15.6"), DistroFamily::OpenSuse);
|
||||
assert_eq!(family_from_label("ARCH_202604"), DistroFamily::Arch);
|
||||
assert_eq!(family_from_label("weird-custom"), DistroFamily::Unknown);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! ISO store: uploads, listing, introspection, boot-entry generation.
|
||||
//!
|
||||
//! An ISO goes through three states:
|
||||
//! 1. **Uploading** — bytes streaming to a `.partial` file under `iso_dir`.
|
||||
//! 2. **Introspecting** — once upload completes, we probe the ISO to detect
|
||||
//! the distro family and extract kernel/initrd if applicable. Metadata
|
||||
//! persisted as a sibling `.meta.json` file.
|
||||
//! 3. **Ready** — listed in the menu, servable over HTTP.
|
||||
//!
|
||||
//! Introspection is best-effort. If we can't identify the distro, the ISO is
|
||||
//! still bootable via a generic `memdisk`/`sanboot` fallback path (not
|
||||
//! recommended but better than nothing).
|
||||
//!
|
||||
//! The `smb` submodule needs exactly one `unsafe` call to `libc::kill` for
|
||||
//! SIGHUP-based Samba reload — the call is documented inline and every
|
||||
//! other file in this crate is `#![forbid(unsafe_code)]`-equivalent via
|
||||
//! the workspace lints.
|
||||
|
||||
pub mod entry;
|
||||
pub mod introspect;
|
||||
pub mod nfs;
|
||||
pub mod smb;
|
||||
pub mod store;
|
||||
pub mod windows;
|
||||
|
||||
pub use entry::{BootEntry, BootKind, KernelArgs};
|
||||
pub use introspect::{DistroFamily, IntrospectionReport};
|
||||
pub use nfs::{NfsAddRequest, NfsManager, NfsMount, NfsVersion};
|
||||
pub use smb::{extract_windows_iso, SmbManager, SmbState};
|
||||
pub use store::{generate_boot_entries_for, slugify_str, IsoMeta, IsoSource, IsoStore, UploadHandle};
|
||||
pub use windows::{WimPatcher, WinPatchState};
|
||||
@@ -0,0 +1,564 @@
|
||||
//! NFS share manager.
|
||||
//!
|
||||
//! Lets an operator mount a remote NFS export as an ISO source instead of
|
||||
//! uploading every ISO into the container's PVC. Supports NFSv3 and
|
||||
//! NFSv4.1 — the two versions the user explicitly asked for.
|
||||
//!
|
||||
//! ## How it works
|
||||
//!
|
||||
//! 1. Operator submits a mount spec via the Storage tab:
|
||||
//! `{ server: "10.0.0.20", export: "/srv/isos", version: "v41" }`.
|
||||
//! 2. We slugify a stable id, mkdir `<work_dir>/nfs/<id>/`, then shell out
|
||||
//! to `/bin/mount -t nfs -o vers=...,ro,nolock server:export local`.
|
||||
//! 3. On success we walk the mount point looking for `*.iso` files and
|
||||
//! register each one with the `IsoStore` as an external source — same
|
||||
//! introspection pipeline as a web upload, but no sha256 (the bytes
|
||||
//! live on a remote machine; hashing them would suck them through the
|
||||
//! network on every restart).
|
||||
//! 4. On failure we record `last_error` on the spec and persist anyway
|
||||
//! so the UI can show a row in red rather than silently dropping it.
|
||||
//!
|
||||
//! ## Operational notes
|
||||
//!
|
||||
//! - Mounting NFS inside a container needs `CAP_SYS_ADMIN` and the
|
||||
//! `nfs-common` package. The default image ships these (see Dockerfile).
|
||||
//! - On OpenShift, the SCC must allow `CAP_SYS_ADMIN`. The bundled SCC
|
||||
//! doesn't — operators have to opt in by switching to a more privileged
|
||||
//! SCC or running NFS mounts as a CSI driver outside the pod.
|
||||
//! - Mount commands are issued sequentially under a single mutex to avoid
|
||||
//! `mount` racing on the same target dir.
|
||||
//!
|
||||
//! ## Persistence
|
||||
//!
|
||||
//! Mount specs (without runtime state) live at `<work_dir>/nfs.json`,
|
||||
//! re-mounted on startup. Mounts that fail to come back online keep their
|
||||
//! spec and their `last_error` so the operator sees what happened.
|
||||
|
||||
use crate::introspect::{introspect, IntrospectionReport};
|
||||
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
||||
use parking_lot::Mutex;
|
||||
use pxeforge_core::{Error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Wire-protocol versions we support. Keep this enum closed — silently
|
||||
/// accepting "auto" or letting the kernel negotiate would mean operators
|
||||
/// could never confirm which version is in use.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NfsVersion {
|
||||
/// NFSv3 — UDP/TCP, separate `mountd` protocol. Required for many
|
||||
/// older NAS appliances.
|
||||
V3,
|
||||
/// NFSv4.1 — single TCP port (2049), session-based. Modern default.
|
||||
V41,
|
||||
}
|
||||
|
||||
impl NfsVersion {
|
||||
fn vers_arg(self) -> &'static str {
|
||||
match self {
|
||||
Self::V3 => "vers=3",
|
||||
Self::V41 => "vers=4.1",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One configured mount. The id is generated from server+export so the
|
||||
/// operator can re-add the same export idempotently.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NfsMount {
|
||||
pub id: String,
|
||||
pub server: String,
|
||||
pub export: String,
|
||||
pub version: NfsVersion,
|
||||
/// Read-only by default — most ISO libraries are. Operators that need
|
||||
/// write can flip this off but PXEForge itself never writes.
|
||||
pub read_only: bool,
|
||||
/// Local mount point under `<work_dir>/nfs/`.
|
||||
pub local_path: PathBuf,
|
||||
/// Whether the mount is currently active.
|
||||
pub mounted: bool,
|
||||
/// Last error encountered on a `mount` or `umount` attempt; cleared on
|
||||
/// success.
|
||||
pub last_error: Option<String>,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub last_attempt: Option<OffsetDateTime>,
|
||||
/// Number of `.iso` files found on the share (re-counted on each scan).
|
||||
pub iso_count: u32,
|
||||
}
|
||||
|
||||
/// Spec submitted by the UI. Server and export are normalized before use.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct NfsAddRequest {
|
||||
pub server: String,
|
||||
pub export: String,
|
||||
#[serde(default = "default_version")]
|
||||
pub version: NfsVersion,
|
||||
#[serde(default = "default_ro")]
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
fn default_version() -> NfsVersion {
|
||||
NfsVersion::V41
|
||||
}
|
||||
fn default_ro() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Inner {
|
||||
mounts: HashMap<String, NfsMount>,
|
||||
}
|
||||
|
||||
/// Manages NFS mounts and surfaces them as ISO sources.
|
||||
///
|
||||
/// Cheap to clone — internal state is `Arc<Mutex<...>>`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NfsManager {
|
||||
work_root: Arc<PathBuf>,
|
||||
state_path: Arc<PathBuf>,
|
||||
inner: Arc<Mutex<Inner>>,
|
||||
iso_store: IsoStore,
|
||||
/// Single-writer lock around the actual `mount`/`umount` shell-outs;
|
||||
/// avoids racing on the same target directory.
|
||||
mount_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
}
|
||||
|
||||
impl NfsManager {
|
||||
/// Construct a manager rooted at `work_dir`. Mount points live under
|
||||
/// `<work_dir>/nfs/<id>/`. State persists to `<work_dir>/nfs.json`.
|
||||
#[must_use]
|
||||
pub fn new(work_dir: &Path, iso_store: IsoStore) -> Self {
|
||||
let work_root = work_dir.join("nfs");
|
||||
let state_path = work_dir.join("nfs.json");
|
||||
Self {
|
||||
work_root: Arc::new(work_root),
|
||||
state_path: Arc::new(state_path),
|
||||
inner: Arc::new(Mutex::new(Inner::default())),
|
||||
iso_store,
|
||||
mount_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where this manager mounts shares. Used by `IsoStore` to resolve
|
||||
/// NFS-backed `IsoMeta`s to their on-disk path.
|
||||
#[must_use]
|
||||
pub fn mount_root(&self) -> PathBuf {
|
||||
self.work_root.as_ref().clone()
|
||||
}
|
||||
|
||||
/// Load persisted state and re-attempt every mount. Errors are logged
|
||||
/// per-mount but never fail the call — startup must not block on a
|
||||
/// remote NFS server being slow.
|
||||
pub async fn load_and_remount(&self) -> Result<()> {
|
||||
tokio::fs::create_dir_all(self.work_root.as_path()).await?;
|
||||
let mounts = match tokio::fs::read_to_string(self.state_path.as_path()).await {
|
||||
Ok(text) => serde_json::from_str::<Vec<NfsMount>>(&text).unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
for mut m in mounts {
|
||||
// Always start from "not mounted" — the kernel state was lost
|
||||
// when the process died. We'll try to remount each one.
|
||||
m.mounted = false;
|
||||
m.last_error = None;
|
||||
self.inner.lock().mounts.insert(m.id.clone(), m.clone());
|
||||
if let Err(e) = self.try_mount(&m.id).await {
|
||||
tracing::warn!(
|
||||
target: "pxeforge::nfs",
|
||||
id = %m.id, error = %e,
|
||||
"could not remount NFS share on startup"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a new mount. Returns the resulting `NfsMount` (with `mounted`
|
||||
/// reflecting reality) or an error if the spec was invalid.
|
||||
pub async fn add(&self, req: NfsAddRequest) -> Result<NfsMount> {
|
||||
let server = req.server.trim().to_string();
|
||||
let export = req.export.trim().to_string();
|
||||
if server.is_empty() {
|
||||
return Err(Error::Invalid("server is required".into()));
|
||||
}
|
||||
if !export.starts_with('/') {
|
||||
return Err(Error::Invalid("export path must start with '/'".into()));
|
||||
}
|
||||
|
||||
let id = mount_id(&server, &export);
|
||||
let local_path = self.work_root.join(&id);
|
||||
tokio::fs::create_dir_all(&local_path).await?;
|
||||
|
||||
let mount = NfsMount {
|
||||
id: id.clone(),
|
||||
server,
|
||||
export,
|
||||
version: req.version,
|
||||
read_only: req.read_only,
|
||||
local_path,
|
||||
mounted: false,
|
||||
last_error: None,
|
||||
last_attempt: None,
|
||||
iso_count: 0,
|
||||
};
|
||||
self.inner.lock().mounts.insert(id.clone(), mount);
|
||||
self.persist_locked();
|
||||
self.try_mount(&id).await?;
|
||||
Ok(self.get(&id).expect("mount just inserted"))
|
||||
}
|
||||
|
||||
/// Unmount and forget a share. Removes any ISOs it contributed from
|
||||
/// the IsoStore and deletes the local mount point. Idempotent.
|
||||
pub async fn remove(&self, id: &str) -> Result<()> {
|
||||
// Best-effort umount; even if it fails (e.g. server unreachable)
|
||||
// we still want to drop the in-memory record.
|
||||
let _ = self.umount_one(id).await;
|
||||
let local_path = {
|
||||
let mut g = self.inner.lock();
|
||||
g.mounts.remove(id).map(|m| m.local_path)
|
||||
};
|
||||
self.persist_locked();
|
||||
self.iso_store.drop_external_source(id);
|
||||
if let Some(p) = local_path {
|
||||
// rmdir only — never recurse, the mount could still be live
|
||||
// on some kernel error path and we don't want to nuke a
|
||||
// remote filesystem.
|
||||
let _ = tokio::fs::remove_dir(&p).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-scan a mounted share for ISOs, refreshing the IsoStore.
|
||||
pub async fn rescan(&self, id: &str) -> Result<u32> {
|
||||
let mount = self
|
||||
.get(id)
|
||||
.ok_or_else(|| Error::Invalid(format!("no such mount '{id}'")))?;
|
||||
if !mount.mounted {
|
||||
return Err(Error::Invalid(format!("mount '{id}' is not active")));
|
||||
}
|
||||
let count = self.scan_and_register(&mount).await?;
|
||||
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
|
||||
m.iso_count = count;
|
||||
}
|
||||
self.persist_locked();
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Snapshot of every configured mount.
|
||||
#[must_use]
|
||||
pub fn list(&self) -> Vec<NfsMount> {
|
||||
let g = self.inner.lock();
|
||||
let mut v: Vec<_> = g.mounts.values().cloned().collect();
|
||||
v.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
v
|
||||
}
|
||||
|
||||
/// Look up a single mount by id.
|
||||
#[must_use]
|
||||
pub fn get(&self, id: &str) -> Option<NfsMount> {
|
||||
self.inner.lock().mounts.get(id).cloned()
|
||||
}
|
||||
|
||||
// ── internals ─────────────────────────────────────────────────────
|
||||
|
||||
async fn try_mount(&self, id: &str) -> Result<()> {
|
||||
let _g = self.mount_lock.lock().await;
|
||||
|
||||
let m = self
|
||||
.get(id)
|
||||
.ok_or_else(|| Error::Invalid(format!("no such mount '{id}'")))?;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
|
||||
// Already mounted? Skip — `mount` would error on a busy target
|
||||
// and confuse the operator's UI status.
|
||||
if is_mountpoint(&m.local_path).await {
|
||||
self.update_status(id, true, None, now);
|
||||
// Even though already mounted, we still want a fresh ISO count.
|
||||
let count = self.scan_and_register(&m).await.unwrap_or(0);
|
||||
self.update_iso_count(id, count);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let opts = mount_options(&m);
|
||||
let target = format!("{}:{}", m.server, m.export);
|
||||
|
||||
let output = Command::new("mount")
|
||||
.arg("-t")
|
||||
.arg("nfs")
|
||||
.arg("-o")
|
||||
.arg(&opts)
|
||||
.arg(&target)
|
||||
.arg(&m.local_path)
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match output {
|
||||
Ok(out) if out.status.success() => {
|
||||
tracing::info!(
|
||||
target: "pxeforge::nfs",
|
||||
id = %id, server = %m.server, export = %m.export,
|
||||
version = ?m.version,
|
||||
"NFS mount succeeded"
|
||||
);
|
||||
self.update_status(id, true, None, now);
|
||||
let count = self.scan_and_register(&m).await.unwrap_or(0);
|
||||
self.update_iso_count(id, count);
|
||||
Ok(())
|
||||
}
|
||||
Ok(out) => {
|
||||
let err = format!(
|
||||
"mount exit {}: {}",
|
||||
out.status.code().unwrap_or(-1),
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
tracing::warn!(target: "pxeforge::nfs", id = %id, "{err}");
|
||||
self.update_status(id, false, Some(err.clone()), now);
|
||||
Err(Error::Invalid(err))
|
||||
}
|
||||
Err(e) => {
|
||||
let err = format!("could not exec /bin/mount: {e}");
|
||||
tracing::error!(target: "pxeforge::nfs", id = %id, "{err}");
|
||||
self.update_status(id, false, Some(err.clone()), now);
|
||||
Err(Error::Invalid(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn umount_one(&self, id: &str) -> Result<()> {
|
||||
let _g = self.mount_lock.lock().await;
|
||||
let Some(m) = self.get(id) else { return Ok(()) };
|
||||
if !is_mountpoint(&m.local_path).await {
|
||||
self.update_status(id, false, None, OffsetDateTime::now_utc());
|
||||
return Ok(());
|
||||
}
|
||||
// -l = lazy: detach immediately, finish when no process has a
|
||||
// handle. Important if a stale ISO read is still in flight.
|
||||
let out = Command::new("umount")
|
||||
.arg("-l")
|
||||
.arg(&m.local_path)
|
||||
.output()
|
||||
.await;
|
||||
match out {
|
||||
Ok(o) if o.status.success() => {
|
||||
self.update_status(id, false, None, OffsetDateTime::now_utc());
|
||||
Ok(())
|
||||
}
|
||||
Ok(o) => {
|
||||
let e = format!(
|
||||
"umount exit {}: {}",
|
||||
o.status.code().unwrap_or(-1),
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
);
|
||||
self.update_status(id, false, Some(e.clone()), OffsetDateTime::now_utc());
|
||||
Err(Error::Invalid(e))
|
||||
}
|
||||
Err(e) => {
|
||||
let e = format!("could not exec /bin/umount: {e}");
|
||||
self.update_status(id, false, Some(e.clone()), OffsetDateTime::now_utc());
|
||||
Err(Error::Invalid(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk the mount point for `*.iso` files, introspect each one, and
|
||||
/// register it with the IsoStore as an NFS-sourced entry. Returns the
|
||||
/// count of ISOs registered.
|
||||
async fn scan_and_register(&self, m: &NfsMount) -> Result<u32> {
|
||||
// Drop any prior entries from this mount before re-registering, so
|
||||
// a removed file disappears from the store.
|
||||
self.iso_store.drop_external_source(&m.id);
|
||||
|
||||
let mut walker = tokio::fs::read_dir(&m.local_path).await?;
|
||||
let mut count = 0u32;
|
||||
while let Some(entry) = walker.next_entry().await? {
|
||||
let p = entry.path();
|
||||
if p.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(str::to_ascii_lowercase)
|
||||
.as_deref()
|
||||
!= Some("iso")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let filename = match p.file_name().and_then(|s| s.to_str()) {
|
||||
Some(f) => f.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
let size = tokio::fs::metadata(&p).await?.len();
|
||||
// Introspection is sync + IO-bound (reads ISO9660 PVD). Push
|
||||
// it to a blocking thread so the runtime stays responsive on
|
||||
// a slow share.
|
||||
let p_owned = p.clone();
|
||||
let report: IntrospectionReport =
|
||||
tokio::task::spawn_blocking(move || introspect(&p_owned))
|
||||
.await
|
||||
.map_err(|e| Error::Other(e.into()))?;
|
||||
let id = format!("nfs-{}-{}", m.id, slugify_str(&filename));
|
||||
let boot_entries = generate_boot_entries_for(&id, &filename, &report);
|
||||
let source = IsoSource::Nfs {
|
||||
mount_id: m.id.clone(),
|
||||
relative_path: filename.clone(),
|
||||
};
|
||||
self.iso_store.register_external(
|
||||
id,
|
||||
filename,
|
||||
size,
|
||||
report,
|
||||
boot_entries,
|
||||
source,
|
||||
);
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn update_status(&self, id: &str, mounted: bool, err: Option<String>, ts: OffsetDateTime) {
|
||||
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
|
||||
m.mounted = mounted;
|
||||
m.last_error = err;
|
||||
m.last_attempt = Some(ts);
|
||||
}
|
||||
self.persist_locked();
|
||||
}
|
||||
|
||||
fn update_iso_count(&self, id: &str, count: u32) {
|
||||
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
|
||||
m.iso_count = count;
|
||||
}
|
||||
self.persist_locked();
|
||||
}
|
||||
|
||||
/// Atomically replace the on-disk JSON with the current state.
|
||||
/// Persistence errors are logged, never propagated — settings live in
|
||||
/// memory authoritatively, matching the SettingsStore policy.
|
||||
fn persist_locked(&self) {
|
||||
let mounts: Vec<NfsMount> = self.inner.lock().mounts.values().cloned().collect();
|
||||
let path = self.state_path.as_path();
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let body = match serde_json::to_vec_pretty(&mounts) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!(target: "pxeforge::nfs", "serialize NFS state: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
if let Err(e) = std::fs::write(&tmp, body) {
|
||||
tracing::warn!(target: "pxeforge::nfs", "write NFS state tmp: {e}");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&tmp, path) {
|
||||
tracing::warn!(target: "pxeforge::nfs", "rename NFS state: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mount_options(m: &NfsMount) -> String {
|
||||
let mut opts = vec![m.version.vers_arg().to_string()];
|
||||
if m.read_only {
|
||||
opts.push("ro".into());
|
||||
} else {
|
||||
opts.push("rw".into());
|
||||
}
|
||||
// `nolock` for v3 — many storage appliances disable lockd; we don't
|
||||
// need locking for read-only ISO access anyway.
|
||||
if matches!(m.version, NfsVersion::V3) {
|
||||
opts.push("nolock".into());
|
||||
}
|
||||
// Soft mount with a generous timeout — better to surface a hung share
|
||||
// as a user-visible error than to wedge the iPXE client forever on a
|
||||
// dead NFS server.
|
||||
opts.push("soft".into());
|
||||
opts.push("timeo=100".into());
|
||||
opts.push("retrans=3".into());
|
||||
opts.join(",")
|
||||
}
|
||||
|
||||
fn mount_id(server: &str, export: &str) -> String {
|
||||
let raw = format!("{server}{export}");
|
||||
slugify_str(&raw)
|
||||
}
|
||||
|
||||
/// Detect whether `path` is currently a mount point. We don't have
|
||||
/// `is_mountpoint(2)`, so compare the parent's device id to the dir's;
|
||||
/// if they differ the dir is a mount.
|
||||
async fn is_mountpoint(path: &Path) -> bool {
|
||||
let Some(parent) = path.parent() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(m1) = tokio::fs::metadata(path).await else {
|
||||
return false;
|
||||
};
|
||||
let Ok(m2) = tokio::fs::metadata(parent).await else {
|
||||
return false;
|
||||
};
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
m1.dev() != m2.dev()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn version_arg() {
|
||||
assert_eq!(NfsVersion::V3.vers_arg(), "vers=3");
|
||||
assert_eq!(NfsVersion::V41.vers_arg(), "vers=4.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mount_options_v3_includes_nolock() {
|
||||
let m = NfsMount {
|
||||
id: "x".into(),
|
||||
server: "s".into(),
|
||||
export: "/e".into(),
|
||||
version: NfsVersion::V3,
|
||||
read_only: true,
|
||||
local_path: PathBuf::from("/tmp/x"),
|
||||
mounted: false,
|
||||
last_error: None,
|
||||
last_attempt: None,
|
||||
iso_count: 0,
|
||||
};
|
||||
let opts = mount_options(&m);
|
||||
assert!(opts.contains("vers=3"));
|
||||
assert!(opts.contains("ro"));
|
||||
assert!(opts.contains("nolock"));
|
||||
assert!(opts.contains("soft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mount_options_v41_no_nolock() {
|
||||
let m = NfsMount {
|
||||
id: "x".into(),
|
||||
server: "s".into(),
|
||||
export: "/e".into(),
|
||||
version: NfsVersion::V41,
|
||||
read_only: false,
|
||||
local_path: PathBuf::from("/tmp/x"),
|
||||
mounted: false,
|
||||
last_error: None,
|
||||
last_attempt: None,
|
||||
iso_count: 0,
|
||||
};
|
||||
let opts = mount_options(&m);
|
||||
assert!(opts.contains("vers=4.1"));
|
||||
assert!(opts.contains("rw"));
|
||||
assert!(!opts.contains("nolock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mount_id_is_stable_and_safe() {
|
||||
let a = mount_id("10.0.0.5", "/srv/isos");
|
||||
let b = mount_id("10.0.0.5", "/srv/isos");
|
||||
assert_eq!(a, b);
|
||||
assert!(!a.contains('/'));
|
||||
assert!(!a.contains('.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
//! SMB share manager. Spawns and supervises `smbd` for serving extracted
|
||||
//! Windows install trees on port 445. This is the server side of the
|
||||
//! Bootimus-pattern Windows boot: WinPE does `net use Z: \\server\<share>`
|
||||
//! and runs Setup from there.
|
||||
//!
|
||||
//! Lifecycle:
|
||||
//!
|
||||
//! 1. Web UI toggles `windows_enabled = true` → `SmbManager::start()`.
|
||||
//! We write an `smb.conf` that declares one share per extracted
|
||||
//! Windows ISO, then `smbd --foreground --no-process-group`.
|
||||
//! 2. When a Windows ISO is uploaded, `extract_windows_iso()` unpacks
|
||||
//! it under `smb_dir/<iso_id>/` and `SmbManager::reconcile_shares()`
|
||||
//! rewrites `smb.conf` and signals smbd to reload (SIGHUP).
|
||||
//! 3. When the toggle flips off, `stop()` sends SIGTERM to smbd and
|
||||
//! leaves the extracted trees in place (in case the toggle comes
|
||||
//! back on).
|
||||
//!
|
||||
//! Safety posture:
|
||||
//! - Guest-mode SMB, read-only (`writable = no`, `guest ok = yes`).
|
||||
//! - SMB2 minimum (no SMB1 legacy, not needed for WinPE).
|
||||
//! - Bound to 0.0.0.0:445; operator MUST put this on a trusted install
|
||||
//! VLAN — guest SMB is not for the general internet.
|
||||
//! - smbd runs as the same non-root uid as pxeforge (10001).
|
||||
//! - If `smbd` isn't on PATH (e.g. lightweight container build without
|
||||
//! Samba), we return `SmbState::SmbdMissing` and the UI surfaces the
|
||||
//! gap. No panics, no retries, no silent failure.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
use parking_lot::Mutex;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case", tag = "state")]
|
||||
pub enum SmbState {
|
||||
/// Windows support is off — smbd not running.
|
||||
Disabled,
|
||||
/// `smbd` is missing from the image. Operator enabled Windows but the
|
||||
/// runtime container didn't include Samba.
|
||||
SmbdMissing,
|
||||
/// Started and healthy.
|
||||
Running { pid: u32, shares: Vec<String> },
|
||||
/// Tried to start but smbd exited. Reason is captured for the UI.
|
||||
Failed { reason: String },
|
||||
}
|
||||
|
||||
pub struct SmbManager {
|
||||
smb_dir: PathBuf,
|
||||
conf_path: PathBuf,
|
||||
child: Arc<Mutex<Option<Child>>>,
|
||||
state: Arc<Mutex<SmbState>>,
|
||||
}
|
||||
|
||||
impl SmbManager {
|
||||
pub fn new(smb_dir: PathBuf) -> Self {
|
||||
let conf_path = smb_dir.join("smb.conf");
|
||||
Self {
|
||||
smb_dir,
|
||||
conf_path,
|
||||
child: Arc::new(Mutex::new(None)),
|
||||
state: Arc::new(Mutex::new(SmbState::Disabled)),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> SmbState {
|
||||
self.state.lock().clone()
|
||||
}
|
||||
|
||||
/// Enumerate `<smb_dir>/*/` sub-dirs as shares. An extracted Windows
|
||||
/// ISO under `smb_dir/<slug>/` becomes a share named `<slug>`. Returns
|
||||
/// the sorted list.
|
||||
pub fn discover_shares(&self) -> Vec<String> {
|
||||
let Ok(rd) = std::fs::read_dir(&self.smb_dir) else { return vec![]; };
|
||||
let mut out: Vec<String> = rd
|
||||
.flatten()
|
||||
.filter(|e| e.path().is_dir())
|
||||
.filter_map(|e| e.file_name().to_str().map(str::to_owned))
|
||||
// Ignore hidden / internal dirs.
|
||||
.filter(|n| !n.starts_with('.') && n != "tmp")
|
||||
.collect();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// Write out `smb.conf` for the currently-discovered shares. Safe to
|
||||
/// call while smbd is running — smbd reloads on SIGHUP.
|
||||
pub fn write_conf(&self) -> std::io::Result<Vec<String>> {
|
||||
std::fs::create_dir_all(&self.smb_dir)?;
|
||||
let shares = self.discover_shares();
|
||||
let mut conf = String::new();
|
||||
conf.push_str(SMB_CONF_GLOBAL);
|
||||
for name in &shares {
|
||||
let path = self.smb_dir.join(name);
|
||||
conf.push_str(&format!(
|
||||
"\n[{name}]\n\
|
||||
path = {}\n\
|
||||
comment = PXEForge Windows install media ({name})\n\
|
||||
read only = yes\n\
|
||||
guest ok = yes\n\
|
||||
guest only = yes\n\
|
||||
browseable = yes\n\
|
||||
available = yes\n",
|
||||
path.display(),
|
||||
));
|
||||
}
|
||||
let tmp = self.conf_path.with_extension("conf.tmp");
|
||||
std::fs::write(&tmp, conf)?;
|
||||
std::fs::rename(tmp, &self.conf_path)?;
|
||||
Ok(shares)
|
||||
}
|
||||
|
||||
/// Start smbd. No-op if already running.
|
||||
pub fn start(&self) -> SmbState {
|
||||
let mut g = self.child.lock();
|
||||
if g.as_ref().map_or(false, |c| c.id() > 0) {
|
||||
return self.state.lock().clone();
|
||||
}
|
||||
if !smbd_present() {
|
||||
let s = SmbState::SmbdMissing;
|
||||
*self.state.lock() = s.clone();
|
||||
return s;
|
||||
}
|
||||
let shares = match self.write_conf() {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let s = SmbState::Failed { reason: format!("write smb.conf: {e}") };
|
||||
*self.state.lock() = s.clone();
|
||||
return s;
|
||||
}
|
||||
};
|
||||
let child = Command::new("smbd")
|
||||
.args([
|
||||
"--foreground",
|
||||
"--no-process-group",
|
||||
"--configfile", self.conf_path.to_str().unwrap_or(""),
|
||||
"--log-stdout",
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn();
|
||||
match child {
|
||||
Ok(c) => {
|
||||
let pid = c.id();
|
||||
*g = Some(c);
|
||||
let s = SmbState::Running { pid, shares };
|
||||
*self.state.lock() = s.clone();
|
||||
tracing::info!(target: "pxeforge::smb", pid, shares=?self.state.lock(), "smbd started");
|
||||
s
|
||||
}
|
||||
Err(e) => {
|
||||
let s = SmbState::Failed { reason: format!("spawn smbd: {e}") };
|
||||
*self.state.lock() = s.clone();
|
||||
s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrite smb.conf and SIGHUP smbd so it picks up new/removed shares.
|
||||
/// No-op if smbd isn't running.
|
||||
#[allow(unsafe_code)]
|
||||
pub fn reconcile(&self) -> SmbState {
|
||||
let mut g = self.child.lock();
|
||||
if g.is_none() { return self.state.lock().clone(); }
|
||||
let shares = match self.write_conf() {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let s = SmbState::Failed { reason: format!("write smb.conf: {e}") };
|
||||
*self.state.lock() = s.clone();
|
||||
return s;
|
||||
}
|
||||
};
|
||||
if let Some(c) = g.as_mut() {
|
||||
let pid = c.id() as i32;
|
||||
// SAFETY: libc::kill is FFI-safe; we pass a pid we own (returned
|
||||
// from `Child::id` above, the child is alive because we hold the
|
||||
// Mutex guard `g`) and a well-defined signal constant. Return
|
||||
// value ignored because there's no meaningful recovery if SIGHUP
|
||||
// fails — the next reconcile will retry.
|
||||
// Rationale for not using a safe wrapper: the only crate that
|
||||
// covers this is `nix`, which pulls ~40 transitive deps for a
|
||||
// single signal send. One documented unsafe call is the better
|
||||
// tradeoff for a container-first project.
|
||||
unsafe { libc::kill(pid, libc::SIGHUP); }
|
||||
let s = SmbState::Running { pid: pid as u32, shares };
|
||||
*self.state.lock() = s.clone();
|
||||
s
|
||||
} else {
|
||||
self.state.lock().clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop smbd. Safe to call repeatedly.
|
||||
pub fn stop(&self) {
|
||||
let mut g = self.child.lock();
|
||||
if let Some(mut c) = g.take() {
|
||||
let _ = c.kill();
|
||||
let _ = c.wait();
|
||||
}
|
||||
*self.state.lock() = SmbState::Disabled;
|
||||
}
|
||||
}
|
||||
|
||||
fn smbd_present() -> bool {
|
||||
let Ok(paths) = std::env::var("PATH") else { return false; };
|
||||
for dir in std::env::split_paths(&paths) {
|
||||
if dir.join("smbd").is_file() { return true; }
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
const SMB_CONF_GLOBAL: &str = r#"[global]
|
||||
workgroup = PXEFORGE
|
||||
server min protocol = SMB2
|
||||
smb ports = 445
|
||||
log level = 1
|
||||
max log size = 1024
|
||||
disable netbios = yes
|
||||
server role = standalone
|
||||
map to guest = Bad User
|
||||
guest account = nobody
|
||||
# Anchor to container-friendly paths; tdb + log files under the data dir
|
||||
# so a read-only rootfs in OpenShift doesn't block Samba.
|
||||
lock directory = /tmp
|
||||
state directory = /tmp
|
||||
cache directory = /tmp
|
||||
pid directory = /tmp
|
||||
"#;
|
||||
|
||||
/// Extract a Windows ISO at `iso_path` into `smb_dir/<slug>/`. Uses
|
||||
/// `7z` when available (most reliable for UDF + ISO9660 hybrid images);
|
||||
/// falls back to `bsdtar -xf` which also handles UDF on many distros.
|
||||
/// Returns the share name (i.e. the slug) on success.
|
||||
///
|
||||
/// Idempotent: if the target dir already contains `sources/boot.wim`, we
|
||||
/// skip extraction. Callers who want a forced re-extract should remove the
|
||||
/// dir first.
|
||||
pub fn extract_windows_iso(iso_path: &Path, smb_dir: &Path, slug: &str) -> std::io::Result<PathBuf> {
|
||||
let target = smb_dir.join(slug);
|
||||
if target.join("sources").join("boot.wim").is_file() {
|
||||
tracing::debug!(target: "pxeforge::smb", slug, "ISO already extracted, skipping");
|
||||
return Ok(target);
|
||||
}
|
||||
std::fs::create_dir_all(&target)?;
|
||||
|
||||
// Try 7z first.
|
||||
if which("7z").is_some() {
|
||||
let out = Command::new("7z")
|
||||
.args(["x", "-y", "-bd", "-bb0"])
|
||||
.arg(format!("-o{}", target.display()))
|
||||
.arg(iso_path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.output()?;
|
||||
if out.status.success() { return Ok(target); }
|
||||
tracing::warn!(
|
||||
target: "pxeforge::smb",
|
||||
stderr=%String::from_utf8_lossy(&out.stderr),
|
||||
"7z extract failed, trying bsdtar"
|
||||
);
|
||||
}
|
||||
// bsdtar fallback.
|
||||
if which("bsdtar").is_some() {
|
||||
let out = Command::new("bsdtar")
|
||||
.args(["-xf"])
|
||||
.arg(iso_path)
|
||||
.args(["-C"])
|
||||
.arg(&target)
|
||||
.output()?;
|
||||
if out.status.success() { return Ok(target); }
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("bsdtar failed: {}", String::from_utf8_lossy(&out.stderr)),
|
||||
));
|
||||
}
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"neither 7z nor bsdtar available for ISO extraction",
|
||||
))
|
||||
}
|
||||
|
||||
fn which(cmd: &str) -> Option<PathBuf> {
|
||||
let paths = std::env::var_os("PATH")?;
|
||||
for dir in std::env::split_paths(&paths) {
|
||||
let p = dir.join(cmd);
|
||||
if p.is_file() { return Some(p); }
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn disabled_by_default() {
|
||||
let dir = tempdir().unwrap();
|
||||
let m = SmbManager::new(dir.path().into());
|
||||
assert_eq!(m.snapshot(), SmbState::Disabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_without_smbd_reports_missing() {
|
||||
// Drop smbd from PATH for this test.
|
||||
let saved = std::env::var_os("PATH");
|
||||
std::env::set_var("PATH", "/usr/nowhere-pxeforge-test");
|
||||
let dir = tempdir().unwrap();
|
||||
let m = SmbManager::new(dir.path().into());
|
||||
let st = m.start();
|
||||
// Restore PATH before asserting so any subsequent failure is legible.
|
||||
if let Some(p) = saved { std::env::set_var("PATH", p); }
|
||||
assert_eq!(st, SmbState::SmbdMissing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_shares_lists_iso_subdirs() {
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("win10-pro")).unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("win11")).unwrap();
|
||||
std::fs::create_dir_all(dir.path().join(".hidden")).unwrap();
|
||||
let m = SmbManager::new(dir.path().into());
|
||||
assert_eq!(m.discover_shares(), vec!["win10-pro", "win11"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_conf_emits_share_blocks() {
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("win10")).unwrap();
|
||||
let m = SmbManager::new(dir.path().into());
|
||||
let shares = m.write_conf().unwrap();
|
||||
assert_eq!(shares, vec!["win10"]);
|
||||
let conf = std::fs::read_to_string(dir.path().join("smb.conf")).unwrap();
|
||||
assert!(conf.contains("[global]"));
|
||||
assert!(conf.contains("[win10]"));
|
||||
assert!(conf.contains("guest ok = yes"));
|
||||
assert!(conf.contains("read only = yes"));
|
||||
assert!(conf.contains("server min protocol = SMB2"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
//! On-disk ISO store with sidecar metadata files.
|
||||
|
||||
use crate::entry::{BootEntry, BootKind, KernelArgs};
|
||||
use crate::introspect::{introspect, DistroFamily, IntrospectionReport};
|
||||
use bytes::Bytes;
|
||||
use parking_lot::RwLock;
|
||||
use pxeforge_core::{Error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
/// Where the bytes for an ISO actually live.
|
||||
///
|
||||
/// The default is `Local` — uploaded ISOs sit in `<iso_dir>/<id>.iso`.
|
||||
/// `Nfs` entries point at a file inside a remote share that the
|
||||
/// `NfsManager` is keeping mounted. We resolve the on-disk path lazily
|
||||
/// in [`IsoStore::iso_path_for`] using the `nfs_root` set at startup.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum IsoSource {
|
||||
Local,
|
||||
Nfs {
|
||||
mount_id: String,
|
||||
/// Path relative to the mount point — typically just the filename.
|
||||
relative_path: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for IsoSource {
|
||||
fn default() -> Self {
|
||||
Self::Local
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IsoMeta {
|
||||
/// Stable slug used in URLs (derived from the uploaded filename).
|
||||
pub id: String,
|
||||
pub filename: String,
|
||||
pub size_bytes: u64,
|
||||
pub sha256_hex: Option<String>,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub uploaded_at: OffsetDateTime,
|
||||
pub introspection: IntrospectionReport,
|
||||
/// Boot entries this ISO currently exposes in the PXE menu. Usually one,
|
||||
/// occasionally two (BIOS + UEFI variant for some RHEL ISOs).
|
||||
pub boot_entries: Vec<BootEntry>,
|
||||
/// Source of the bytes — local upload (default) or NFS mount.
|
||||
/// Old `meta.json` files without this field deserialize as `Local`.
|
||||
#[serde(default)]
|
||||
pub source: IsoSource,
|
||||
}
|
||||
|
||||
pub struct UploadHandle {
|
||||
pub id: String,
|
||||
pub partial_path: PathBuf,
|
||||
final_path: PathBuf,
|
||||
filename: String,
|
||||
hasher: Sha256,
|
||||
bytes_written: u64,
|
||||
file: tokio::fs::File,
|
||||
}
|
||||
|
||||
impl UploadHandle {
|
||||
pub async fn write_chunk(&mut self, chunk: &Bytes) -> Result<()> {
|
||||
self.file.write_all(chunk).await?;
|
||||
self.hasher.update(chunk);
|
||||
self.bytes_written += chunk.len() as u64;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flush, close, and atomically rename to the final path. Returns the
|
||||
/// final `IsoMeta` including introspection results.
|
||||
pub async fn finish(mut self, store: &IsoStore) -> Result<IsoMeta> {
|
||||
self.file.flush().await?;
|
||||
self.file.sync_all().await?;
|
||||
drop(self.file);
|
||||
tokio::fs::rename(&self.partial_path, &self.final_path).await?;
|
||||
|
||||
let hash = hex::encode(self.hasher.finalize());
|
||||
let introspection = {
|
||||
let p = self.final_path.clone();
|
||||
tokio::task::spawn_blocking(move || introspect(&p))
|
||||
.await
|
||||
.map_err(|e| Error::Other(e.into()))?
|
||||
};
|
||||
|
||||
let boot_entries = generate_boot_entries(&self.id, &self.filename, &introspection);
|
||||
let meta = IsoMeta {
|
||||
id: self.id.clone(),
|
||||
filename: self.filename,
|
||||
size_bytes: self.bytes_written,
|
||||
sha256_hex: Some(hash),
|
||||
uploaded_at: OffsetDateTime::now_utc(),
|
||||
introspection,
|
||||
boot_entries,
|
||||
source: IsoSource::Local,
|
||||
};
|
||||
store.persist_meta(&meta).await?;
|
||||
store.insert(meta.clone());
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
pub async fn abort(self) -> Result<()> {
|
||||
drop(self.file);
|
||||
let _ = tokio::fs::remove_file(&self.partial_path).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Inner {
|
||||
isos: HashMap<String, IsoMeta>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IsoStore {
|
||||
iso_dir: Arc<PathBuf>,
|
||||
/// Where NFS mounts land on disk. Set at startup via
|
||||
/// [`IsoStore::set_nfs_root`]; required for resolving any
|
||||
/// `IsoSource::Nfs` entry.
|
||||
nfs_root: Arc<RwLock<Option<PathBuf>>>,
|
||||
inner: Arc<RwLock<Inner>>,
|
||||
}
|
||||
|
||||
impl IsoStore {
|
||||
pub fn new(iso_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
iso_dir: Arc::new(iso_dir),
|
||||
nfs_root: Arc::new(RwLock::new(None)),
|
||||
inner: Arc::new(RwLock::new(Inner::default())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the store where NFS mounts live. Without this set,
|
||||
/// `IsoSource::Nfs` entries cannot be resolved to a file path.
|
||||
pub fn set_nfs_root(&self, root: PathBuf) {
|
||||
*self.nfs_root.write() = Some(root);
|
||||
}
|
||||
|
||||
pub async fn ensure_dirs(&self) -> Result<()> {
|
||||
tokio::fs::create_dir_all(self.iso_dir.as_path()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Scan the ISO directory on startup and load any sidecar `.meta.json`
|
||||
/// files. ISOs without metadata are introspected lazily — we don't block
|
||||
/// startup on potentially many GB of scanning.
|
||||
pub async fn load_from_disk(&self) -> Result<()> {
|
||||
self.ensure_dirs().await?;
|
||||
let mut entries = tokio::fs::read_dir(self.iso_dir.as_path()).await?;
|
||||
while let Some(e) = entries.next_entry().await? {
|
||||
let p = e.path();
|
||||
if p.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
|
||||
if !p.file_name().and_then(|s| s.to_str()).map_or(false, |n| n.ends_with(".meta.json")) {
|
||||
continue;
|
||||
}
|
||||
if let Ok(text) = tokio::fs::read_to_string(&p).await {
|
||||
if let Ok(meta) = serde_json::from_str::<IsoMeta>(&text) {
|
||||
self.insert(meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert(&self, meta: IsoMeta) {
|
||||
self.inner.write().isos.insert(meta.id.clone(), meta);
|
||||
}
|
||||
|
||||
async fn persist_meta(&self, meta: &IsoMeta) -> Result<()> {
|
||||
let path = self.meta_path(&meta.id);
|
||||
let text = serde_json::to_string_pretty(meta).map_err(|e| Error::Other(e.into()))?;
|
||||
tokio::fs::write(path, text).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn meta_path(&self, id: &str) -> PathBuf {
|
||||
self.iso_dir.join(format!("{id}.meta.json"))
|
||||
}
|
||||
|
||||
fn iso_path(&self, id: &str) -> PathBuf {
|
||||
self.iso_dir.join(format!("{id}.iso"))
|
||||
}
|
||||
|
||||
pub async fn begin_upload(&self, filename: &str) -> Result<UploadHandle> {
|
||||
self.ensure_dirs().await?;
|
||||
let id = slugify(filename);
|
||||
let final_path = self.iso_path(&id);
|
||||
if final_path.exists() {
|
||||
return Err(Error::Invalid(format!("iso '{id}' already exists")));
|
||||
}
|
||||
let partial_path = self.iso_dir.join(format!("{id}.partial"));
|
||||
let file = tokio::fs::File::create(&partial_path).await?;
|
||||
Ok(UploadHandle {
|
||||
id,
|
||||
partial_path,
|
||||
final_path,
|
||||
filename: filename.to_string(),
|
||||
hasher: Sha256::new(),
|
||||
bytes_written: 0,
|
||||
file,
|
||||
})
|
||||
}
|
||||
|
||||
/// Readiness probe — is the backing directory reachable? Distinct from
|
||||
/// "is there content in it", to avoid an empty store failing health.
|
||||
#[must_use]
|
||||
pub fn list_ok(&self) -> bool {
|
||||
std::fs::read_dir(self.iso_dir.as_path()).is_ok()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn list(&self) -> Vec<IsoMeta> {
|
||||
let g = self.inner.read();
|
||||
let mut v: Vec<_> = g.isos.values().cloned().collect();
|
||||
v.sort_by(|a, b| b.uploaded_at.cmp(&a.uploaded_at));
|
||||
v
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(&self, id: &str) -> Option<IsoMeta> {
|
||||
self.inner.read().isos.get(id).cloned()
|
||||
}
|
||||
|
||||
/// Resolve an ISO id to its on-disk path, if any. For local entries
|
||||
/// this is `<iso_dir>/<id>.iso`; for NFS entries it's
|
||||
/// `<nfs_root>/<mount_id>/<relative_path>`. Returns None if the file
|
||||
/// is missing or the source isn't resolvable (e.g. NFS share
|
||||
/// unmounted).
|
||||
pub fn iso_path_for(&self, id: &str) -> Option<PathBuf> {
|
||||
let meta = self.get(id)?;
|
||||
let path = match &meta.source {
|
||||
IsoSource::Local => self.iso_path(id),
|
||||
IsoSource::Nfs {
|
||||
mount_id,
|
||||
relative_path,
|
||||
} => {
|
||||
let root = self.nfs_root.read().clone()?;
|
||||
root.join(mount_id).join(relative_path)
|
||||
}
|
||||
};
|
||||
if path.exists() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete an ISO and its sidecar metadata. Only acts on local ISOs;
|
||||
/// for NFS-backed ISOs the operator must remove the file from the
|
||||
/// share or unmount the NFS share entirely.
|
||||
pub async fn delete(&self, id: &str) -> Result<()> {
|
||||
let meta = self.get(id);
|
||||
let is_local = matches!(meta.as_ref().map(|m| &m.source), Some(IsoSource::Local) | None);
|
||||
if is_local {
|
||||
let iso = self.iso_path(id);
|
||||
let meta_path = self.meta_path(id);
|
||||
let _ = tokio::fs::remove_file(&iso).await;
|
||||
let _ = tokio::fs::remove_file(&meta_path).await;
|
||||
}
|
||||
self.inner.write().isos.remove(id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register an externally-sourced ISO (e.g. NFS-mounted). Used by
|
||||
/// `NfsManager` after walking a freshly-mounted share. We do **not**
|
||||
/// persist a `meta.json` on disk for these — the source of truth is
|
||||
/// the share itself, and the NFS manager re-scans on startup.
|
||||
pub fn register_external(
|
||||
&self,
|
||||
id: String,
|
||||
filename: String,
|
||||
size_bytes: u64,
|
||||
introspection: IntrospectionReport,
|
||||
boot_entries: Vec<BootEntry>,
|
||||
source: IsoSource,
|
||||
) {
|
||||
let meta = IsoMeta {
|
||||
id: id.clone(),
|
||||
filename,
|
||||
size_bytes,
|
||||
sha256_hex: None,
|
||||
uploaded_at: OffsetDateTime::now_utc(),
|
||||
introspection,
|
||||
boot_entries,
|
||||
source,
|
||||
};
|
||||
self.inner.write().isos.insert(id, meta);
|
||||
}
|
||||
|
||||
/// Drop every entry that belongs to `mount_id`. Used by the NFS
|
||||
/// manager when an operator removes a share, or before re-scanning
|
||||
/// to clean out stale entries.
|
||||
pub fn drop_external_source(&self, mount_id: &str) {
|
||||
let mut g = self.inner.write();
|
||||
g.isos.retain(|_, m| {
|
||||
!matches!(&m.source, IsoSource::Nfs { mount_id: mid, .. } if mid == mount_id)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn slugify(filename: &str) -> String {
|
||||
let stem = Path::new(filename)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("iso");
|
||||
slugify_str(stem)
|
||||
}
|
||||
|
||||
/// Slugify an arbitrary string to lowercase ASCII alphanumerics, hyphens,
|
||||
/// and underscores. Public so the NFS manager can mint ids that follow the
|
||||
/// same rules as upload-time ISO ids.
|
||||
#[must_use]
|
||||
pub fn slugify_str(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||
c.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim_matches('-')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Public wrapper around [`generate_boot_entries`] so the NFS manager can
|
||||
/// build entries for shares it just scanned, using the same logic as the
|
||||
/// upload pipeline. Re-exported via the crate root.
|
||||
#[must_use]
|
||||
pub fn generate_boot_entries_for(
|
||||
id: &str,
|
||||
filename: &str,
|
||||
r: &IntrospectionReport,
|
||||
) -> Vec<BootEntry> {
|
||||
generate_boot_entries(id, filename, r)
|
||||
}
|
||||
|
||||
/// Build `BootEntry`s from the introspection report. URLs are relative —
|
||||
/// the HTTP layer rewrites them with the public base URL per request.
|
||||
fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> Vec<BootEntry> {
|
||||
let title = r.volume_label.clone().unwrap_or_else(|| filename.to_string());
|
||||
match r.family {
|
||||
DistroFamily::WindowsPe if r.has_boot_wim => {
|
||||
// Standard wimboot chain. Paths are in-ISO; the HTTP layer maps
|
||||
// `iso/<id>/<path>` to on-disk extraction via ISO9660 lookup.
|
||||
let base = format!("iso/{id}");
|
||||
vec![BootEntry {
|
||||
id: format!("{id}-winpe"),
|
||||
title: format!("{title} (Windows / wimboot)"),
|
||||
kind: BootKind::Wimboot {
|
||||
wimboot_url: "ipxe/wimboot".to_string(),
|
||||
files: vec![
|
||||
("bootmgr".into(), format!("{base}/bootmgr")),
|
||||
("bootmgr.efi".into(), format!("{base}/bootmgr.efi")),
|
||||
("bcd".into(), format!("{base}/boot/bcd")),
|
||||
("boot.sdi".into(), format!("{base}/boot/boot.sdi")),
|
||||
("boot.wim".into(), format!("{base}/sources/boot.wim")),
|
||||
],
|
||||
},
|
||||
}]
|
||||
}
|
||||
fam if r.kernel_path.is_some() => {
|
||||
let base = format!("iso/{id}");
|
||||
let kernel_url = format!("{base}{}", r.kernel_path.as_deref().unwrap_or(""));
|
||||
let initrd_urls = r.initrd_paths.iter().map(|p| format!("{base}{p}")).collect();
|
||||
let args = KernelArgs { cmdline: linux_cmdline(fam, id) };
|
||||
vec![BootEntry {
|
||||
id: format!("{id}-linux"),
|
||||
title,
|
||||
kind: BootKind::LinuxKernel { kernel_url, initrd_urls, args },
|
||||
}]
|
||||
}
|
||||
_ => {
|
||||
// Last-resort SAN boot. Won't work for large modern ISOs, but
|
||||
// lets the ISO at least appear in the menu.
|
||||
vec![BootEntry {
|
||||
id: format!("{id}-sanboot"),
|
||||
title: format!("{title} (SAN boot — may fail for >1GiB ISOs)"),
|
||||
kind: BootKind::SanBootIso { iso_url: format!("iso/{id}.iso") },
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn linux_cmdline(family: DistroFamily, id: &str) -> String {
|
||||
// The HTTP layer resolves `${base-url}` at render time.
|
||||
let iso_url = format!("${{base-url}}/iso/{id}.iso");
|
||||
match family {
|
||||
DistroFamily::DebianUbuntu => format!(
|
||||
"boot=casper netboot=url url={iso_url} ip=dhcp ---"
|
||||
),
|
||||
DistroFamily::RhelFedora => format!(
|
||||
"inst.repo={iso_url} inst.stage2={iso_url} ip=dhcp"
|
||||
),
|
||||
DistroFamily::OpenSuse => format!(
|
||||
"install={iso_url} netsetup=dhcp"
|
||||
),
|
||||
DistroFamily::Arch => format!(
|
||||
"archiso_http_srv=${{base-url}}/iso/ archisobasedir=arch ip=dhcp copytoram"
|
||||
),
|
||||
DistroFamily::Alpine => format!(
|
||||
"alpine_repo=${{base-url}}/iso/{id}/ modloop=${{base-url}}/iso/{id}/boot/modloop-lts ip=dhcp"
|
||||
),
|
||||
_ => "ip=dhcp".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn slugify_basic() {
|
||||
// Upload filenames come from multipart parts (no path components);
|
||||
// file_stem drops the extension, then non-alphanumerics become `-`.
|
||||
assert_eq!(slugify("Ubuntu 24.04 Desktop.iso"), "ubuntu-24-04-desktop");
|
||||
assert_eq!(slugify("Rocky-9.4-x86_64-dvd.iso"), "rocky-9-4-x86_64-dvd");
|
||||
assert_eq!(slugify("arch.iso"), "arch");
|
||||
// If a path sneaks in, file_stem strips the directory — OK, not a hazard.
|
||||
assert_eq!(slugify("/etc/passwd"), "passwd");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! Windows ISO post-processing. Patches `boot.wim` (image index 2, WinPE)
|
||||
//! with two plain-text files so the client hits our SMB share and runs
|
||||
//! Windows Setup from there.
|
||||
//!
|
||||
//! Credit: the *technique* (not the code) is adapted from Bootimus
|
||||
//! (Apache-2.0, https://github.com/garybowers/bootimus). We reimplement in
|
||||
//! Rust and shell out to `wimlib-imagex` at container runtime because
|
||||
//! there is no maintained pure-Rust wimlib binding.
|
||||
//!
|
||||
//! What we inject — and why these are safe:
|
||||
//!
|
||||
//! * `Windows/System32/winpeshl.ini`: a plain INI that WinPE reads at
|
||||
//! startup and uses to launch `startnet.cmd` instead of the default
|
||||
//! interactive shell. No driver, no executable, no signed code.
|
||||
//!
|
||||
//! * `Windows/System32/startnet.cmd`: a batch file that runs `wpeinit`,
|
||||
//! waits for a DHCP lease, then `net use Z: \\<server>\<share> /user:guest`
|
||||
//! and invokes `Z:\setup.exe`. Everything the client executes is stock
|
||||
//! Microsoft-signed WinPE + `setup.exe`. We add zero native code to
|
||||
//! the client's boot path. The trust store is untouched.
|
||||
//!
|
||||
//! What we *do not* inject:
|
||||
//! * No `.sys` drivers, signed or otherwise.
|
||||
//! * No `.cer`, no registry hive edits, no `bcdedit` changes.
|
||||
//! * No `bypass*` Windows 11 tweaks (operators who want those can use an
|
||||
//! unattend.xml; they will never be injected silently by us).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// Public identifier of whether/how Windows patching ran for an ISO.
|
||||
/// Stored on `IsoMeta` so the UI can show a clear "SMB ready" indicator.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WinPatchState {
|
||||
/// Not a Windows ISO, nothing to do.
|
||||
NotApplicable,
|
||||
/// Windows ISO detected but Windows support is disabled in settings.
|
||||
DisabledBySettings,
|
||||
/// wimlib-imagex isn't on PATH — operator needs to install the runtime
|
||||
/// dependency before Windows ISOs can be patched.
|
||||
WimlibMissing,
|
||||
/// Patching succeeded; the ISO's boot.wim was rewritten in-place.
|
||||
Patched { smb_host: String, smb_share: String },
|
||||
/// wimlib returned an error.
|
||||
Failed { reason: String },
|
||||
}
|
||||
|
||||
pub struct WimPatcher {
|
||||
pub smb_host: String,
|
||||
pub smb_share: String,
|
||||
}
|
||||
|
||||
impl WimPatcher {
|
||||
#[must_use]
|
||||
pub fn new(smb_host: String, smb_share: String) -> Self {
|
||||
Self { smb_host, smb_share }
|
||||
}
|
||||
|
||||
/// Apply WinPE patches to `boot.wim` inside `extracted_iso_dir`. Returns
|
||||
/// a state enum — never panics. Designed to be safely re-runnable; each
|
||||
/// call rebuilds image 2 from scratch via `wimlib-imagex update`.
|
||||
pub fn patch(&self, extracted_iso_dir: &Path) -> WinPatchState {
|
||||
if !wimlib_present() {
|
||||
return WinPatchState::WimlibMissing;
|
||||
}
|
||||
let boot_wim = extracted_iso_dir.join("sources").join("boot.wim");
|
||||
if !boot_wim.exists() {
|
||||
// Not a standard Windows install ISO layout.
|
||||
return WinPatchState::NotApplicable;
|
||||
}
|
||||
|
||||
let work = match tempfile::tempdir() {
|
||||
Ok(d) => d,
|
||||
Err(e) => return WinPatchState::Failed { reason: format!("tempdir: {e}") },
|
||||
};
|
||||
|
||||
// Stage the two files we want present at /Windows/System32/.
|
||||
let staging = work.path().join("stage/Windows/System32");
|
||||
if let Err(e) = std::fs::create_dir_all(&staging) {
|
||||
return WinPatchState::Failed { reason: format!("staging mkdir: {e}") };
|
||||
}
|
||||
if let Err(e) = std::fs::write(staging.join("winpeshl.ini"), WINPESHL_INI) {
|
||||
return WinPatchState::Failed { reason: format!("write winpeshl.ini: {e}") };
|
||||
}
|
||||
let startnet = render_startnet(&self.smb_host, &self.smb_share);
|
||||
if let Err(e) = std::fs::write(staging.join("startnet.cmd"), startnet) {
|
||||
return WinPatchState::Failed { reason: format!("write startnet.cmd: {e}") };
|
||||
}
|
||||
|
||||
// Build a wimlib update command file:
|
||||
// add <stage>/Windows/System32 /Windows/System32
|
||||
let update_file = work.path().join("update.cmd");
|
||||
let update_cmd = format!(
|
||||
"add \"{}\" \"/Windows/System32\"\n",
|
||||
staging.display()
|
||||
);
|
||||
if let Err(e) = std::fs::write(&update_file, update_cmd) {
|
||||
return WinPatchState::Failed { reason: format!("write update.cmd: {e}") };
|
||||
}
|
||||
|
||||
// Run wimlib-imagex update against image index 2 (WinPE).
|
||||
let output = Command::new("wimlib-imagex")
|
||||
.arg("update")
|
||||
.arg(&boot_wim)
|
||||
.arg("2")
|
||||
.arg("--rebuild")
|
||||
.arg("--command-file")
|
||||
.arg(&update_file)
|
||||
.output();
|
||||
match output {
|
||||
Ok(o) if o.status.success() => WinPatchState::Patched {
|
||||
smb_host: self.smb_host.clone(),
|
||||
smb_share: self.smb_share.clone(),
|
||||
},
|
||||
Ok(o) => WinPatchState::Failed {
|
||||
reason: format!(
|
||||
"wimlib-imagex update failed (exit {:?}): {}",
|
||||
o.status.code(),
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
),
|
||||
},
|
||||
Err(e) => WinPatchState::Failed { reason: format!("spawn wimlib-imagex: {e}") },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wimlib_present() -> bool {
|
||||
which("wimlib-imagex").is_some()
|
||||
}
|
||||
|
||||
fn which(cmd: &str) -> Option<PathBuf> {
|
||||
let paths = std::env::var_os("PATH")?;
|
||||
for dir in std::env::split_paths(&paths) {
|
||||
let p = dir.join(cmd);
|
||||
if p.is_file() { return Some(p); }
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The winpeshl.ini contents. This file tells WinPE "don't run cmd.exe
|
||||
/// interactively; run startnet.cmd and exit when it returns".
|
||||
const WINPESHL_INI: &str = "[LaunchApps]\r\n\
|
||||
\"%SYSTEMROOT%\\system32\\startnet.cmd\"\r\n";
|
||||
|
||||
/// Render startnet.cmd. The script:
|
||||
/// 1. Loads WinPE networking (`wpeinit`) and renews DHCP.
|
||||
/// 2. Waits until the SMB server is reachable.
|
||||
/// 3. Maps the install share to Z: as guest.
|
||||
/// 4. Runs setup.exe from the share.
|
||||
///
|
||||
/// Uses CRLF line endings because WinPE cmd.exe requires them for .cmd files
|
||||
/// created on unix hosts.
|
||||
fn render_startnet(host: &str, share: &str) -> String {
|
||||
let mut s = String::new();
|
||||
let host = host.trim();
|
||||
let share = share.trim_matches('/');
|
||||
s.push_str("@echo off\r\n");
|
||||
s.push_str("echo PXEForge WinPE bootstrap\r\n");
|
||||
s.push_str("wpeinit\r\n");
|
||||
s.push_str("ipconfig /renew\r\n");
|
||||
s.push_str(&format!("echo Waiting for SMB server {host} to be reachable...\r\n"));
|
||||
s.push_str(&format!(":waitsmb\r\nping -n 1 -w 500 {host} >nul && goto havenet\r\ntimeout /t 2 /nobreak >nul\r\ngoto waitsmb\r\n"));
|
||||
s.push_str(":havenet\r\n");
|
||||
s.push_str(&format!("echo Mapping install media from \\\\{host}\\{share}...\r\n"));
|
||||
s.push_str(&format!(":mapshare\r\nnet use Z: \\\\{host}\\{share} /user:guest \"\" /persistent:no && goto mapped\r\ntimeout /t 3 /nobreak >nul\r\ngoto mapshare\r\n"));
|
||||
s.push_str(":mapped\r\n");
|
||||
s.push_str("echo Starting Windows Setup\r\n");
|
||||
s.push_str("Z:\\setup.exe\r\n");
|
||||
s.push_str("echo Setup exited; dropping to cmd for diagnosis\r\n");
|
||||
s.push_str("cmd\r\n");
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn startnet_has_crlf_and_no_testsigning() {
|
||||
let s = render_startnet("10.0.0.5", "win11");
|
||||
assert!(s.contains("\r\n"));
|
||||
// Hard guard: must never include trust-store or driver-policy mutations.
|
||||
assert!(!s.to_lowercase().contains("bcdedit"));
|
||||
assert!(!s.to_lowercase().contains("testsigning"));
|
||||
assert!(!s.to_lowercase().contains("certutil"));
|
||||
assert!(s.contains("net use Z:"));
|
||||
assert!(s.contains("setup.exe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patcher_reports_wimlib_missing_gracefully() {
|
||||
// We don't assume wimlib is present in CI; this checks the missing
|
||||
// branch is the noisy-but-survivable one we expect.
|
||||
let patcher = WimPatcher::new("10.0.0.5".into(), "win11".into());
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// Construct a fake "sources/boot.wim".
|
||||
std::fs::create_dir_all(dir.path().join("sources")).unwrap();
|
||||
std::fs::write(dir.path().join("sources/boot.wim"), b"placeholder").unwrap();
|
||||
let result = patcher.patch(dir.path());
|
||||
// Depending on whether wimlib is installed on the runner, we get
|
||||
// either WimlibMissing or Failed(...). Both mean "no silent
|
||||
// success with trust-store mutation" — that's the invariant.
|
||||
assert!(matches!(
|
||||
result,
|
||||
WinPatchState::WimlibMissing | WinPatchState::Failed { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "pxeforge"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Container-native PXE boot server — a lightweight Rust clone of iVentoy"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "pxeforge"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
pxeforge-core.workspace = true
|
||||
pxeforge-dhcp-proxy.workspace = true
|
||||
pxeforge-tftp.workspace = true
|
||||
pxeforge-http-api.workspace = true
|
||||
pxeforge-iso-store.workspace = true
|
||||
pxeforge-ipxe-assets.workspace = true
|
||||
tokio.workspace = true
|
||||
axum.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
serde.workspace = true
|
||||
toml.workspace = true
|
||||
bytes.workspace = true
|
||||
time.workspace = true
|
||||
@@ -0,0 +1,372 @@
|
||||
//! PXEForge entry point. Wires the three protocol servers (DHCP proxy,
|
||||
//! TFTP, HTTP) to the shared ISO store and client registry, then runs
|
||||
//! them concurrently.
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use pxeforge_core::{ClientRegistry, Config, DhcpMode, GateQueue, LogBus, LogBusLayer, SettingsStore};
|
||||
use pxeforge_dhcp_proxy::DhcpProxyServer;
|
||||
use pxeforge_http_api::{build_router, AppState};
|
||||
use pxeforge_iso_store::{IsoStore, NfsManager, SmbManager};
|
||||
use std::sync::Arc;
|
||||
use pxeforge_tftp::TftpServer;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "pxeforge", about = "Container-native PXE boot server", version)]
|
||||
struct Cli {
|
||||
/// Path to a TOML config file. All fields have sensible defaults and can
|
||||
/// also be overridden with env vars (PXEFORGE_*).
|
||||
#[arg(long, env = "PXEFORGE_CONFIG")]
|
||||
config: Option<PathBuf>,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
/// Import every `.iso` from a host directory into the ISO store, running
|
||||
/// the same introspection + boot-entry generation pass the web upload
|
||||
/// does. Useful for pre-populating the store before starting the server
|
||||
/// (e.g. in a CI setup or after copying ISOs to a fresh PVC).
|
||||
///
|
||||
/// Example:
|
||||
/// docker run --rm \
|
||||
/// -v /my/isos:/seed:ro \
|
||||
/// -v pxeforge-data:/var/lib/pxeforge/isos \
|
||||
/// pxeforge:0.1.0 seed --from /seed
|
||||
Seed {
|
||||
/// Source directory containing one or more `.iso` files.
|
||||
#[arg(long)]
|
||||
from: PathBuf,
|
||||
/// Don't actually import — print what would happen.
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// The LogBus has to exist before we install tracing layers, since one
|
||||
// of those layers fans out into it. The web UI's Terminal tab
|
||||
// subscribes to this bus over SSE.
|
||||
let log_bus = LogBus::new(500);
|
||||
init_tracing(log_bus.clone());
|
||||
|
||||
let cli = Cli::parse();
|
||||
let mut config = match &cli.config {
|
||||
Some(p) if p.exists() => Config::from_toml_file(p)?,
|
||||
_ => Config::default(),
|
||||
};
|
||||
config.apply_env();
|
||||
|
||||
// Dispatch subcommands before bringing up the server.
|
||||
if let Some(cmd) = cli.command {
|
||||
return run_command(cmd, config).await;
|
||||
}
|
||||
|
||||
pxeforge_ipxe_assets::log_availability();
|
||||
|
||||
let our_ip = match config.server.public_ip {
|
||||
Some(ip) => {
|
||||
tracing::info!("using configured public IP: {ip}");
|
||||
ip
|
||||
}
|
||||
None => match detect_primary_ipv4() {
|
||||
Some(ip) => {
|
||||
tracing::info!("auto-detected public IPv4: {ip}");
|
||||
ip
|
||||
}
|
||||
None => {
|
||||
// Without a reachable IP, every generated iPXE URL would
|
||||
// be unreachable from real clients. Exit with a clear
|
||||
// message instead of serving a broken deployment.
|
||||
anyhow::bail!(
|
||||
"could not detect a non-loopback IPv4 address for this host. \
|
||||
Set PXEFORGE_PUBLIC_IP=<your-ip> (e.g. `-e PXEFORGE_PUBLIC_IP=10.0.0.5` \
|
||||
in docker, or the env block in OpenShift Deployment) to advertise \
|
||||
a specific IP to PXE clients."
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
let public_base_url = format!("http://{our_ip}");
|
||||
|
||||
let iso_store = IsoStore::new(config.paths.iso_dir.clone());
|
||||
iso_store.load_from_disk().await?;
|
||||
let clients = ClientRegistry::new();
|
||||
let gates = GateQueue::new();
|
||||
let settings = SettingsStore::load_or_default(&config.paths.work_dir);
|
||||
|
||||
// Build the SMB manager unconditionally — it starts/stops on the
|
||||
// Windows toggle, not at process start. If the `smb_dir` isn't
|
||||
// writable (e.g. read-only filesystem), the manager will surface that
|
||||
// as `SmbState::Failed` when the operator flips the toggle.
|
||||
let smb = Arc::new(SmbManager::new(config.paths.smb_dir.clone()));
|
||||
if settings.snapshot().windows_enabled {
|
||||
let _ = smb.start();
|
||||
}
|
||||
|
||||
// NFS manager. The mount root has to be set on the IsoStore *before*
|
||||
// we replay any persisted mounts, otherwise an in-memory IsoMeta
|
||||
// pointing at an NFS source can't resolve to a path.
|
||||
let nfs = NfsManager::new(&config.paths.work_dir, iso_store.clone());
|
||||
iso_store.set_nfs_root(nfs.mount_root());
|
||||
if let Err(e) = nfs.load_and_remount().await {
|
||||
tracing::warn!(target: "pxeforge::nfs", "could not reload NFS mounts: {e}");
|
||||
}
|
||||
|
||||
// Sniff network details for the Network tab. None of these are
|
||||
// required for PXE to work — they're informational, surfaced in the
|
||||
// UI so an operator doesn't have to drop to a shell to find their
|
||||
// own gateway.
|
||||
let net = detect_network_info(our_ip);
|
||||
tracing::info!(
|
||||
target: "pxeforge::net",
|
||||
nic = %net.nic_name, mask = %net.subnet_mask, gateway = %net.gateway,
|
||||
"network info"
|
||||
);
|
||||
|
||||
let state = AppState {
|
||||
iso_store: iso_store.clone(),
|
||||
clients: clients.clone(),
|
||||
settings: settings.clone(),
|
||||
gates: gates.clone(),
|
||||
smb: Some(smb.clone()),
|
||||
nfs: nfs.clone(),
|
||||
log_bus: log_bus.clone(),
|
||||
started_at: time::OffsetDateTime::now_utc(),
|
||||
public_base_url: public_base_url.clone(),
|
||||
nic_name: net.nic_name,
|
||||
subnet_mask: net.subnet_mask,
|
||||
gateway: net.gateway,
|
||||
};
|
||||
|
||||
let http_addr = SocketAddr::new(config.server.http_bind, config.server.http_port);
|
||||
let router = build_router(state);
|
||||
let http_task = tokio::spawn(async move {
|
||||
let listener = tokio::net::TcpListener::bind(http_addr).await?;
|
||||
tracing::info!(target: "pxeforge::http", "HTTP listening on {http_addr}");
|
||||
axum::serve(listener, router).await?;
|
||||
Ok::<_, anyhow::Error>(())
|
||||
});
|
||||
|
||||
let tftp = TftpServer::new(config.server.tftp_bind, config.server.tftp_port, clients.clone());
|
||||
let tftp_task = tokio::spawn(tftp.run());
|
||||
|
||||
let dhcp_task: tokio::task::JoinHandle<anyhow::Result<()>> = match config.network.dhcp_mode {
|
||||
DhcpMode::Proxy => {
|
||||
let s = DhcpProxyServer::new(
|
||||
config.network.dhcp_bind,
|
||||
config.network.dhcp_port,
|
||||
config.network.pxe_port,
|
||||
our_ip,
|
||||
public_base_url.clone(),
|
||||
clients.clone(),
|
||||
);
|
||||
tokio::spawn(s.run())
|
||||
}
|
||||
DhcpMode::Disabled => {
|
||||
tracing::info!(target: "pxeforge::dhcp", "DHCP disabled — external DHCP must set next-server + filename");
|
||||
tokio::spawn(async { futures_forever().await })
|
||||
}
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
r = http_task => { tracing::error!("http task exited: {:?}", r); r??; }
|
||||
r = tftp_task => { tracing::error!("tftp task exited: {:?}", r); r??; }
|
||||
r = dhcp_task => { tracing::error!("dhcp task exited: {:?}", r); r??; }
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn futures_forever() -> anyhow::Result<()> {
|
||||
std::future::pending::<()>().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_command(cmd: Command, config: Config) -> anyhow::Result<()> {
|
||||
match cmd {
|
||||
Command::Seed { from, dry_run } => seed_from_dir(&from, &config, dry_run).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk `src` for `*.iso`, stream each file through the normal upload path.
|
||||
/// Reuses `IsoStore::begin_upload` / `finish` so the resulting meta on disk
|
||||
/// is identical to a web upload — same slug rules, same introspection, same
|
||||
/// sha256.
|
||||
async fn seed_from_dir(src: &std::path::Path, config: &Config, dry_run: bool) -> anyhow::Result<()> {
|
||||
let store = IsoStore::new(config.paths.iso_dir.clone());
|
||||
store.load_from_disk().await?;
|
||||
let mut entries = tokio::fs::read_dir(src).await?;
|
||||
let mut imported = 0u32;
|
||||
let mut skipped = 0u32;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let p = entry.path();
|
||||
if p.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() != Some("iso") {
|
||||
continue;
|
||||
}
|
||||
let filename = p
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("non-utf8 filename: {}", p.display()))?
|
||||
.to_string();
|
||||
println!(" {} ({} bytes)", filename, tokio::fs::metadata(&p).await?.len());
|
||||
if dry_run { continue; }
|
||||
|
||||
let mut handle = match store.begin_upload(&filename).await {
|
||||
Ok(h) => h,
|
||||
Err(pxeforge_core::Error::Invalid(e)) => {
|
||||
eprintln!(" skip: {e}");
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let mut file = tokio::fs::File::open(&p).await?;
|
||||
let mut buf = vec![0u8; 1024 * 1024];
|
||||
loop {
|
||||
let n = file.read(&mut buf).await?;
|
||||
if n == 0 { break; }
|
||||
let chunk: bytes::Bytes = buf[..n].to_vec().into();
|
||||
handle.write_chunk(&chunk).await?;
|
||||
}
|
||||
let meta = handle.finish(&store).await?;
|
||||
println!(" -> id={} family={:?}", meta.id, meta.introspection.family);
|
||||
imported += 1;
|
||||
}
|
||||
println!("\nimported={imported} skipped={skipped} {}", if dry_run { "(dry run)" } else { "" });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pick the first non-loopback IPv4 address on this host. Returns `None` if
|
||||
/// detection fails — callers should fail startup rather than silently using
|
||||
/// a loopback address (which would give every PXE client an unreachable
|
||||
/// `http://127.0.0.1/...`). Users in multi-homed setups should set
|
||||
/// `PXEFORGE_PUBLIC_IP` explicitly.
|
||||
fn detect_primary_ipv4() -> Option<Ipv4Addr> {
|
||||
// First try: route to the public internet. `UdpSocket::connect` to a
|
||||
// well-known external address causes the OS to populate `local_addr`
|
||||
// with the source IP it would use — this is the standard "which of my
|
||||
// interfaces is the internet-facing one" idiom.
|
||||
if let Ok(sock) = std::net::UdpSocket::bind("0.0.0.0:0") {
|
||||
if sock.connect("8.8.8.8:80").is_ok() {
|
||||
if let Ok(std::net::SocketAddr::V4(addr)) = sock.local_addr() {
|
||||
let v4 = *addr.ip();
|
||||
if !v4.is_loopback() && !v4.is_unspecified() {
|
||||
return Some(v4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: hostname resolution.
|
||||
if let Ok(hostname) = hostname() {
|
||||
if let Ok(addrs) = std::net::ToSocketAddrs::to_socket_addrs(&format!("{hostname}:0")) {
|
||||
for a in addrs {
|
||||
if let std::net::IpAddr::V4(v4) = a.ip() {
|
||||
if !v4.is_loopback() && !v4.is_unspecified() {
|
||||
return Some(v4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn hostname() -> std::io::Result<String> {
|
||||
// Tiny shim: read /proc/sys/kernel/hostname on Linux, fall back to `uname -n` via env.
|
||||
if let Ok(h) = std::fs::read_to_string("/proc/sys/kernel/hostname") {
|
||||
return Ok(h.trim().to_string());
|
||||
}
|
||||
std::env::var("HOSTNAME").map_err(|_| std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound, "no hostname",
|
||||
))
|
||||
}
|
||||
|
||||
fn init_tracing(bus: Arc<LogBus>) {
|
||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||
let filter = EnvFilter::try_from_env("PXEFORGE_LOG")
|
||||
.unwrap_or_else(|_| EnvFilter::new("info,pxeforge=debug"));
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(fmt::layer().with_target(true))
|
||||
.with(LogBusLayer::new(bus))
|
||||
.init();
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct NetworkInfo {
|
||||
nic_name: String,
|
||||
subnet_mask: String,
|
||||
gateway: String,
|
||||
}
|
||||
|
||||
/// Best-effort population of the Network tab's read-only fields. We shell
|
||||
/// out to standard Linux tools (`ip route`) instead of pulling in a
|
||||
/// netlink crate — these calls run once at startup, so the cost of
|
||||
/// spawning a process is negligible. Empty strings are perfectly fine
|
||||
/// fallbacks; the UI shows a `?` placeholder.
|
||||
fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo {
|
||||
use std::process::Command;
|
||||
let mut info = NetworkInfo::default();
|
||||
|
||||
// `ip -o -f inet addr show` lists every interface with its
|
||||
// `inet a.b.c.d/mask`. We match the line that mentions our IP.
|
||||
if let Ok(out) = Command::new("ip").args(["-o", "-f", "inet", "addr", "show"]).output() {
|
||||
if let Ok(text) = String::from_utf8(out.stdout) {
|
||||
for line in text.lines() {
|
||||
if !line.contains(&our_ip.to_string()) {
|
||||
continue;
|
||||
}
|
||||
// Format: "2: enp1s0 inet 10.0.0.5/24 brd ..."
|
||||
let mut parts = line.split_whitespace();
|
||||
let _idx = parts.next();
|
||||
if let Some(name) = parts.next() {
|
||||
info.nic_name = name.trim_end_matches(':').to_string();
|
||||
}
|
||||
if let Some(addr) = line.split_whitespace().find(|p| p.contains('/')) {
|
||||
if let Some((_, prefix_str)) = addr.split_once('/') {
|
||||
if let Ok(prefix) = prefix_str.parse::<u8>() {
|
||||
info.subnet_mask = prefix_to_dotted(prefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `ip route show default` -> "default via 10.0.0.1 dev enp1s0 ..."
|
||||
if let Ok(out) = Command::new("ip").args(["route", "show", "default"]).output() {
|
||||
if let Ok(text) = String::from_utf8(out.stdout) {
|
||||
if let Some(line) = text.lines().next() {
|
||||
let mut parts = line.split_whitespace();
|
||||
while let Some(p) = parts.next() {
|
||||
if p == "via" {
|
||||
if let Some(gw) = parts.next() {
|
||||
info.gateway = gw.to_string();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info
|
||||
}
|
||||
|
||||
fn prefix_to_dotted(prefix: u8) -> String {
|
||||
let prefix = prefix.min(32);
|
||||
let mask: u32 = if prefix == 0 { 0 } else { u32::MAX << (32 - prefix) };
|
||||
format!(
|
||||
"{}.{}.{}.{}",
|
||||
(mask >> 24) & 0xff,
|
||||
(mask >> 16) & 0xff,
|
||||
(mask >> 8) & 0xff,
|
||||
mask & 0xff
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "pxeforge-tftp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "TFTP server (RFC 1350/2347/2348/2349/7440) for iPXE chainload"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
pxeforge-core.workspace = true
|
||||
pxeforge-ipxe-assets.workspace = true
|
||||
tokio.workspace = true
|
||||
socket2.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
bytes.workspace = true
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Minimal TFTP server sufficient to deliver iPXE binaries (~1 MiB each)
|
||||
//! to firmware PXE ROMs. Only implements RRQ (read requests) because we
|
||||
//! never receive WRQs in our use case.
|
||||
//!
|
||||
//! Implements RFC 1350 base protocol plus:
|
||||
//! - RFC 2347 option negotiation (OACK)
|
||||
//! - RFC 2348 `blksize` (critical — default 512 makes transfers unusably slow)
|
||||
//! - RFC 2349 `tsize` (some PXE ROMs require it)
|
||||
//! - RFC 7440 `windowsize` (huge throughput improvement for supporting clients)
|
||||
//!
|
||||
//! Files served are backed by the embedded iPXE asset store; there is no
|
||||
//! filesystem path traversal surface because we only look up by asset name.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod server;
|
||||
|
||||
pub use server::TftpServer;
|
||||
@@ -0,0 +1,443 @@
|
||||
//! TFTP server implementation.
|
||||
//!
|
||||
//! Design: the main socket on :69 accepts RRQ packets. For each RRQ we spawn
|
||||
//! a task that creates a new ephemeral UDP socket and handles the full
|
||||
//! transfer there (per RFC 1350 — each transfer uses its own port pair so
|
||||
//! multiple clients can download concurrently). This matches exactly how
|
||||
//! `tftpd`/`in.tftpd` works and is why TFTP is awkward behind stateful NAT:
|
||||
//! the ephemeral ports must be reachable from the client.
|
||||
//!
|
||||
//! We only serve files from `pxeforge_ipxe_assets::asset_bytes` — that is,
|
||||
//! the bundled iPXE binaries and wimboot. No filesystem is ever opened, so
|
||||
//! `../` path traversal attempts simply return ENOENT.
|
||||
|
||||
use pxeforge_core::{ClientEvent, ClientRegistry};
|
||||
use pxeforge_ipxe_assets::asset_bytes;
|
||||
use socket2::{Domain, Protocol, Socket, Type};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
// TFTP opcodes.
|
||||
const OP_RRQ: u16 = 1;
|
||||
const OP_DATA: u16 = 3;
|
||||
const OP_ACK: u16 = 4;
|
||||
const OP_ERROR: u16 = 5;
|
||||
const OP_OACK: u16 = 6;
|
||||
|
||||
// Error codes (RFC 1350).
|
||||
const ERR_NOT_DEFINED: u16 = 0;
|
||||
const ERR_FILE_NOT_FOUND: u16 = 1;
|
||||
const ERR_ILLEGAL_OP: u16 = 4;
|
||||
|
||||
pub struct TftpServer {
|
||||
bind: IpAddr,
|
||||
port: u16,
|
||||
clients: Arc<ClientRegistry>,
|
||||
}
|
||||
|
||||
impl TftpServer {
|
||||
pub fn new(bind: IpAddr, port: u16, clients: Arc<ClientRegistry>) -> Self {
|
||||
Self { bind, port, clients }
|
||||
}
|
||||
|
||||
pub async fn run(self) -> anyhow::Result<()> {
|
||||
let sock = bind_udp(self.bind, self.port)?;
|
||||
tracing::info!(target: "pxeforge::tftp", "TFTP listening on {}:{}", self.bind, self.port);
|
||||
let clients = self.clients.clone();
|
||||
let mut buf = vec![0u8; 2048];
|
||||
loop {
|
||||
let (n, from) = match sock.recv_from(&mut buf).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(target: "pxeforge::tftp", "recv error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let data = buf[..n].to_vec();
|
||||
let clients = clients.clone();
|
||||
let bind_ip = self.bind;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_rrq(data, from, bind_ip, clients).await {
|
||||
tracing::warn!(target: "pxeforge::tftp", peer=%from, "handler error: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_rrq(
|
||||
packet: Vec<u8>,
|
||||
peer: SocketAddr,
|
||||
bind_ip: IpAddr,
|
||||
clients: Arc<ClientRegistry>,
|
||||
) -> anyhow::Result<()> {
|
||||
let req = match parse_rrq(&packet) {
|
||||
Some(r) => r,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let Request { filename, options, .. } = req;
|
||||
|
||||
// Per-transfer ephemeral socket.
|
||||
let sock = bind_udp(bind_ip, 0)?;
|
||||
|
||||
let Some(file_bytes) = asset_bytes(&filename) else {
|
||||
let _ = send_error(&sock, peer, ERR_FILE_NOT_FOUND, "no such file").await;
|
||||
tracing::info!(target: "pxeforge::tftp", peer=%peer, file=%filename, "404");
|
||||
clients.record(
|
||||
&peer.ip().to_string(),
|
||||
Some(peer.ip()),
|
||||
None,
|
||||
ClientEvent::TftpRead { file: filename.clone() },
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
target: "pxeforge::tftp",
|
||||
peer=%peer, file=%filename, size=file_bytes.len(),
|
||||
"serving"
|
||||
);
|
||||
clients.record(
|
||||
&peer.ip().to_string(),
|
||||
Some(peer.ip()),
|
||||
None,
|
||||
ClientEvent::TftpRead { file: filename.clone() },
|
||||
);
|
||||
|
||||
// Negotiate options.
|
||||
let mut blksize: usize = 512;
|
||||
let mut window: u16 = 1;
|
||||
let mut accepted_opts: Vec<(String, String)> = Vec::new();
|
||||
|
||||
for (k, v) in &options {
|
||||
match k.as_str() {
|
||||
"blksize" => {
|
||||
if let Ok(n) = v.parse::<usize>() {
|
||||
blksize = n.clamp(8, 65464);
|
||||
accepted_opts.push(("blksize".into(), blksize.to_string()));
|
||||
}
|
||||
}
|
||||
"tsize" => {
|
||||
accepted_opts.push(("tsize".into(), file_bytes.len().to_string()));
|
||||
}
|
||||
"windowsize" => {
|
||||
if let Ok(n) = v.parse::<u16>() {
|
||||
window = n.clamp(1, 64);
|
||||
accepted_opts.push(("windowsize".into(), window.to_string()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !accepted_opts.is_empty() {
|
||||
let oack = encode_oack(&accepted_opts);
|
||||
// Loop until client ACKs block 0 (the OACK).
|
||||
if !wait_for_ack(&sock, peer, 0, &oack).await? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// DATA transfer. Block numbers are u16 and may wrap at 65535 — we handle
|
||||
// that via `wrapping_add`. For each window we remember the starting
|
||||
// (offset, block_no) explicitly; on retransmit we replay from there
|
||||
// instead of trying to compute it back from `last_block_in_window` which
|
||||
// is wrong when a window short-sends at EOF (previous bug: window=8 but
|
||||
// only 3 blocks sent, then rewind subtracted 7 landing in the wrong id).
|
||||
let total = file_bytes.len();
|
||||
let mut offset: usize = 0;
|
||||
let mut block_no: u16 = 1;
|
||||
let mut needs_zero_final = false; // spec: if last data block == blksize, follow with empty DATA
|
||||
|
||||
'transfer: loop {
|
||||
let window_start_offset = offset;
|
||||
let window_start_block = block_no;
|
||||
let mut last_block_in_window = block_no;
|
||||
let mut window_reached_eof = false;
|
||||
let mut last_chunk_len = 0usize;
|
||||
|
||||
// Send one window worth of DATA.
|
||||
for _ in 0..window {
|
||||
if offset >= total { break; }
|
||||
let end = (offset + blksize).min(total);
|
||||
let chunk = &file_bytes[offset..end];
|
||||
let pkt = encode_data(block_no, chunk);
|
||||
sock.send_to(&pkt, peer).await?;
|
||||
last_block_in_window = block_no;
|
||||
last_chunk_len = chunk.len();
|
||||
offset = end;
|
||||
block_no = block_no.wrapping_add(1);
|
||||
if end == total {
|
||||
window_reached_eof = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for an ACK of the last block we actually sent (not the
|
||||
// nominal last block of a theoretical full window).
|
||||
let mut tries = 0u8;
|
||||
loop {
|
||||
match tokio::time::timeout(Duration::from_secs(3), recv_ack(&sock, peer)).await {
|
||||
Ok(Ok(acked)) if acked == last_block_in_window => break,
|
||||
Ok(Ok(_)) => continue, // stale ACK from an earlier block — ignore
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => {
|
||||
tries += 1;
|
||||
if tries > 5 {
|
||||
tracing::warn!(
|
||||
target: "pxeforge::tftp",
|
||||
peer=%peer, last_block=last_block_in_window,
|
||||
"timeout after {tries} retries, aborting transfer"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
// Rewind to the start of this window and resend exactly
|
||||
// the same blocks (same count, same block numbers). This
|
||||
// is cheap and correct even for short final windows.
|
||||
offset = window_start_offset;
|
||||
block_no = window_start_block;
|
||||
let mut resent = 0;
|
||||
while resent < window && offset < total {
|
||||
let end = (offset + blksize).min(total);
|
||||
let pkt = encode_data(block_no, &file_bytes[offset..end]);
|
||||
sock.send_to(&pkt, peer).await?;
|
||||
last_block_in_window = block_no;
|
||||
last_chunk_len = end - offset;
|
||||
offset = end;
|
||||
block_no = block_no.wrapping_add(1);
|
||||
resent += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if window_reached_eof || offset >= total {
|
||||
// If the very last DATA was exactly blksize, RFC 1350 requires a
|
||||
// following zero-length DATA to signal end-of-transfer. If it
|
||||
// was shorter, the short block already signals EOF.
|
||||
needs_zero_final = last_chunk_len == blksize;
|
||||
break 'transfer;
|
||||
}
|
||||
}
|
||||
|
||||
if needs_zero_final {
|
||||
let pkt = encode_data(block_no, &[]);
|
||||
sock.send_to(&pkt, peer).await?;
|
||||
let _ = tokio::time::timeout(Duration::from_secs(3), recv_ack(&sock, peer)).await;
|
||||
}
|
||||
|
||||
tracing::debug!(target: "pxeforge::tftp", peer=%peer, bytes=total, "transfer complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Request {
|
||||
filename: String,
|
||||
#[allow(dead_code)]
|
||||
mode: String,
|
||||
options: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
fn parse_rrq(pkt: &[u8]) -> Option<Request> {
|
||||
if pkt.len() < 4 { return None; }
|
||||
let op = u16::from_be_bytes([pkt[0], pkt[1]]);
|
||||
if op != OP_RRQ { return None; }
|
||||
let mut rest = &pkt[2..];
|
||||
let filename = read_cstr(&mut rest)?;
|
||||
let mode = read_cstr(&mut rest)?;
|
||||
let mut options = Vec::new();
|
||||
while !rest.is_empty() {
|
||||
let k = match read_cstr(&mut rest) { Some(s) => s, None => break };
|
||||
if k.is_empty() { break; }
|
||||
let v = read_cstr(&mut rest).unwrap_or_default();
|
||||
options.push((k.to_ascii_lowercase(), v));
|
||||
}
|
||||
Some(Request { filename, mode, options })
|
||||
}
|
||||
|
||||
fn read_cstr(buf: &mut &[u8]) -> Option<String> {
|
||||
let pos = buf.iter().position(|b| *b == 0)?;
|
||||
let s = std::str::from_utf8(&buf[..pos]).ok()?.to_string();
|
||||
*buf = &buf[pos + 1..];
|
||||
Some(s)
|
||||
}
|
||||
|
||||
fn encode_data(block: u16, chunk: &[u8]) -> Vec<u8> {
|
||||
let mut v = Vec::with_capacity(4 + chunk.len());
|
||||
v.extend_from_slice(&OP_DATA.to_be_bytes());
|
||||
v.extend_from_slice(&block.to_be_bytes());
|
||||
v.extend_from_slice(chunk);
|
||||
v
|
||||
}
|
||||
|
||||
fn encode_oack(opts: &[(String, String)]) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&OP_OACK.to_be_bytes());
|
||||
for (k, val) in opts {
|
||||
v.extend_from_slice(k.as_bytes());
|
||||
v.push(0);
|
||||
v.extend_from_slice(val.as_bytes());
|
||||
v.push(0);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
async fn send_error(
|
||||
sock: &UdpSocket,
|
||||
peer: SocketAddr,
|
||||
code: u16,
|
||||
msg: &str,
|
||||
) -> std::io::Result<()> {
|
||||
let mut v = Vec::with_capacity(5 + msg.len());
|
||||
v.extend_from_slice(&OP_ERROR.to_be_bytes());
|
||||
v.extend_from_slice(&code.to_be_bytes());
|
||||
v.extend_from_slice(msg.as_bytes());
|
||||
v.push(0);
|
||||
sock.send_to(&v, peer).await.map(|_| ())
|
||||
}
|
||||
|
||||
async fn recv_ack(sock: &UdpSocket, peer: SocketAddr) -> anyhow::Result<u16> {
|
||||
let mut buf = [0u8; 32];
|
||||
loop {
|
||||
let (n, from) = sock.recv_from(&mut buf).await?;
|
||||
if from.ip() != peer.ip() { continue; }
|
||||
if n < 4 { continue; }
|
||||
let op = u16::from_be_bytes([buf[0], buf[1]]);
|
||||
match op {
|
||||
OP_ACK => return Ok(u16::from_be_bytes([buf[2], buf[3]])),
|
||||
OP_ERROR => {
|
||||
let code = u16::from_be_bytes([buf[2], buf[3]]);
|
||||
anyhow::bail!("client error {code}");
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_ack(
|
||||
sock: &UdpSocket,
|
||||
peer: SocketAddr,
|
||||
expect_block: u16,
|
||||
to_retx: &[u8],
|
||||
) -> anyhow::Result<bool> {
|
||||
let mut tries = 0;
|
||||
loop {
|
||||
sock.send_to(to_retx, peer).await?;
|
||||
match tokio::time::timeout(Duration::from_secs(3), recv_ack(sock, peer)).await {
|
||||
Ok(Ok(b)) if b == expect_block => return Ok(true),
|
||||
Ok(Ok(_)) => continue,
|
||||
Ok(Err(_)) | Err(_) => {
|
||||
tries += 1;
|
||||
if tries > 5 { return Ok(false); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn bind_udp(bind: IpAddr, port: u16) -> anyhow::Result<UdpSocket> {
|
||||
let domain = match bind { IpAddr::V4(_) => Domain::IPV4, IpAddr::V6(_) => Domain::IPV6 };
|
||||
let sock = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP))?;
|
||||
sock.set_reuse_address(true)?;
|
||||
sock.set_nonblocking(true)?;
|
||||
let addr: SocketAddr = SocketAddr::new(bind, port);
|
||||
sock.bind(&addr.into())?;
|
||||
let std_sock: std::net::UdpSocket = sock.into();
|
||||
Ok(UdpSocket::from_std(std_sock)?)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
const _UNUSED: (u16, u16) = (ERR_NOT_DEFINED, ERR_ILLEGAL_OP);
|
||||
|
||||
/// Pure-logic helper used by the unit tests below and (in a refactor) by
|
||||
/// `handle_rrq`. Given a position in the file and the window, return the
|
||||
/// (block_no, chunk_len) list this window will emit. Useful as a sanity
|
||||
/// check that our windowing math matches the wire behavior the spec
|
||||
/// requires — tested against edge cases (exact-blksize tail, short tail,
|
||||
/// single-block window).
|
||||
#[must_use]
|
||||
pub fn plan_window(
|
||||
total: usize,
|
||||
offset: usize,
|
||||
blksize: usize,
|
||||
window: u16,
|
||||
starting_block: u16,
|
||||
) -> Vec<(u16, usize)> {
|
||||
let mut out = Vec::new();
|
||||
let mut o = offset;
|
||||
let mut b = starting_block;
|
||||
for _ in 0..window {
|
||||
if o >= total { break; }
|
||||
let end = (o + blksize).min(total);
|
||||
out.push((b, end - o));
|
||||
o = end;
|
||||
b = b.wrapping_add(1);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_rrq_with_options() {
|
||||
// RRQ "snponly.efi" mode "octet" blksize=1468 tsize=0
|
||||
let mut pkt = vec![0, OP_RRQ as u8];
|
||||
pkt.extend_from_slice(b"snponly.efi\0octet\0blksize\01468\0tsize\00\0");
|
||||
let r = parse_rrq(&pkt).unwrap();
|
||||
assert_eq!(r.filename, "snponly.efi");
|
||||
assert_eq!(r.mode, "octet");
|
||||
assert_eq!(r.options.len(), 2);
|
||||
assert_eq!(r.options[0].0, "blksize");
|
||||
assert_eq!(r.options[0].1, "1468");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_decode_data() {
|
||||
let p = encode_data(7, b"hello");
|
||||
assert_eq!(&p[0..2], &OP_DATA.to_be_bytes());
|
||||
assert_eq!(&p[2..4], &7u16.to_be_bytes());
|
||||
assert_eq!(&p[4..], b"hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_window_full_blocks() {
|
||||
// 4 KB file, 1024 blksize, window 4 → one window of 4 full blocks.
|
||||
let p = plan_window(4096, 0, 1024, 4, 1);
|
||||
assert_eq!(p, vec![(1, 1024), (2, 1024), (3, 1024), (4, 1024)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_window_short_tail_at_eof() {
|
||||
// 3.5 KB file, 1024 blksize, window 8 starting at offset 0.
|
||||
// Expect 3 full + 1 half, then stop (below 8 blocks).
|
||||
let p = plan_window(3584, 0, 1024, 8, 1);
|
||||
assert_eq!(p, vec![(1, 1024), (2, 1024), (3, 1024), (4, 512)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_window_exact_boundary_needs_zero_final() {
|
||||
// 2 KB file, 1024 blksize, window 8 — last block is exactly blksize.
|
||||
// `handle_rrq` checks `last_chunk_len == blksize` to decide whether to
|
||||
// emit the terminating zero-length DATA. Assert that condition here.
|
||||
let p = plan_window(2048, 0, 1024, 8, 1);
|
||||
assert_eq!(p, vec![(1, 1024), (2, 1024)]);
|
||||
let last = p.last().unwrap();
|
||||
assert_eq!(last.1, 1024); // => needs zero final per RFC 1350
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_window_wraparound() {
|
||||
// Block number wraps from u16::MAX to 0 on next window — 2 blocks,
|
||||
// starting at 65534.
|
||||
let p = plan_window(2048, 0, 1024, 2, 65534);
|
||||
assert_eq!(p, vec![(65534, 1024), (65535, 1024)]);
|
||||
let p2 = plan_window(2048, 2048, 1024, 2, 0);
|
||||
assert!(p2.is_empty()); // nothing past EOF
|
||||
// And a cross-boundary case:
|
||||
let p3 = plan_window(3072, 0, 1024, 3, 65535);
|
||||
assert_eq!(p3, vec![(65535, 1024), (0, 1024), (1, 1024)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "pxeforge-webui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Embedded single-file web UI for PXEForge"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,371 @@
|
||||
/* PXEForge web UI — Netbox-style layout, fully offline.
|
||||
* Design tokens are CSS variables so a later phase can re-theme without
|
||||
* touching markup or JS. */
|
||||
|
||||
:root {
|
||||
--bg: #0b1018;
|
||||
--bg-panel: #121826;
|
||||
--bg-panel-2: #1a2334;
|
||||
--bg-elev: #223047;
|
||||
--fg: #e4e8ef;
|
||||
--fg-dim: #8a94a7;
|
||||
--fg-dimmer: #5a6379;
|
||||
--accent: #00d4b4; /* Netbox-ish teal */
|
||||
--accent-dim: #07a38c;
|
||||
--warn: #ffb347;
|
||||
--err: #ef6e6e;
|
||||
--ok: #4ade80;
|
||||
--border: #223047;
|
||||
--border-soft: #172033;
|
||||
--radius: 6px;
|
||||
--radius-lg: 10px;
|
||||
--sidebar-w: 240px;
|
||||
--topbar-h: 54px;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0; font-family: var(--sans); font-size: 14px; line-height: 1.5;
|
||||
background: var(--bg); color: var(--fg);
|
||||
}
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
code, kbd { font-family: var(--mono); font-size: 12.5px;
|
||||
background: var(--bg-panel-2); padding: 1px 5px; border-radius: 3px; }
|
||||
|
||||
/* ── Shell ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-w) 1fr;
|
||||
grid-template-rows: var(--topbar-h) 1fr;
|
||||
grid-template-areas:
|
||||
"sidebar topbar"
|
||||
"sidebar main";
|
||||
height: 100vh;
|
||||
}
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
background: var(--bg-panel);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.sidebar .brand {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sidebar .brand img { width: 40px; height: auto; }
|
||||
.sidebar .brand strong { font-size: 16px; letter-spacing: 0.4px; }
|
||||
.sidebar .brand .sub { color: var(--fg-dim); font-size: 11px; }
|
||||
.sidebar nav { padding: 10px 0; flex: 1; overflow-y: auto; }
|
||||
.sidebar nav .group {
|
||||
padding: 10px 18px 6px;
|
||||
font-size: 10.5px; color: var(--fg-dimmer); text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.sidebar nav a {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 7px 18px; color: var(--fg); font-size: 13.5px;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.sidebar nav a:hover { background: var(--bg-panel-2); text-decoration: none; }
|
||||
.sidebar nav a.active {
|
||||
background: var(--bg-panel-2);
|
||||
border-left-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.sidebar nav a .count {
|
||||
margin-left: auto;
|
||||
background: var(--bg-elev); color: var(--fg-dim);
|
||||
padding: 1px 7px; font-size: 11px; border-radius: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.sidebar nav a.active .count { background: var(--accent); color: #002923; }
|
||||
.sidebar .footer {
|
||||
padding: 10px 18px; border-top: 1px solid var(--border);
|
||||
color: var(--fg-dimmer); font-size: 11px;
|
||||
}
|
||||
.sidebar .footer code { background: transparent; color: var(--fg-dim); padding: 0; }
|
||||
|
||||
/* ── Top bar ───────────────────────────────────────────────────────── */
|
||||
|
||||
.topbar {
|
||||
grid-area: topbar;
|
||||
display: flex; align-items: center;
|
||||
padding: 0 20px; gap: 18px;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.topbar h1 {
|
||||
margin: 0; font-size: 15px; font-weight: 600;
|
||||
color: var(--fg); letter-spacing: 0.2px;
|
||||
}
|
||||
.topbar .tabs { display: flex; gap: 4px; margin-left: 24px; }
|
||||
.topbar .tabs button {
|
||||
background: transparent; border: 0;
|
||||
color: var(--fg-dim); font: inherit;
|
||||
padding: 10px 14px; cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.topbar .tabs button:hover { color: var(--fg); }
|
||||
.topbar .tabs button.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.topbar .spacer { flex: 1; }
|
||||
.topbar .chip {
|
||||
background: var(--bg-panel-2); border: 1px solid var(--border);
|
||||
color: var(--fg-dim); font-size: 12px;
|
||||
padding: 4px 10px; border-radius: 12px;
|
||||
}
|
||||
.topbar .chip strong { color: var(--fg); font-weight: 600; }
|
||||
|
||||
/* ── Main content ─────────────────────────────────────────────────── */
|
||||
|
||||
.main {
|
||||
grid-area: main;
|
||||
overflow: auto;
|
||||
padding: 22px 26px 40px;
|
||||
}
|
||||
.grid { display: grid; gap: 20px; }
|
||||
.grid.cols-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.grid.cols-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
@media (max-width: 1024px) {
|
||||
.grid.cols-3, .grid.cols-2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ── Cards / panels ───────────────────────────────────────────────── */
|
||||
|
||||
.card {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.card > header {
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-panel-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.card > header h2 { margin: 0; font-size: 13.5px; font-weight: 600; color: var(--fg); }
|
||||
.card > header .sub { color: var(--fg-dim); font-size: 12px; margin-left: auto; }
|
||||
.card .body { padding: 16px; }
|
||||
|
||||
.stat {
|
||||
padding: 16px;
|
||||
}
|
||||
.stat .label { color: var(--fg-dim); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.8px; }
|
||||
.stat .value { font-size: 28px; font-weight: 600; line-height: 1.1; margin-top: 4px; color: var(--fg); }
|
||||
.stat .trend { font-size: 12px; color: var(--fg-dim); margin-top: 4px; }
|
||||
|
||||
/* ── Tables ───────────────────────────────────────────────────────── */
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 9px 16px; border-bottom: 1px solid var(--border-soft); }
|
||||
th {
|
||||
color: var(--fg-dim); font-weight: 500; font-size: 11px;
|
||||
text-transform: uppercase; letter-spacing: 1px;
|
||||
background: var(--bg-panel-2);
|
||||
}
|
||||
tr:hover td { background: var(--bg-panel-2); }
|
||||
td.mono { font-family: var(--mono); font-size: 12.5px; }
|
||||
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ── Tags / pills ─────────────────────────────────────────────────── */
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px; border-radius: 10px;
|
||||
font-size: 11px; font-weight: 600;
|
||||
background: #1b3148; color: #a2c5e8;
|
||||
}
|
||||
.tag.ok { background: #103428; color: var(--ok); }
|
||||
.tag.warn { background: #3a2a10; color: var(--warn); }
|
||||
.tag.err { background: #3a1515; color: var(--err); }
|
||||
.tag.accent { background: #072f29; color: var(--accent); }
|
||||
.tag.arch { text-transform: uppercase; }
|
||||
|
||||
/* ── Forms ────────────────────────────────────────────────────────── */
|
||||
|
||||
button, .btn {
|
||||
background: var(--accent); color: #002923;
|
||||
border: 0; border-radius: var(--radius);
|
||||
padding: 7px 14px; font: inherit; font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover, .btn:hover { background: var(--accent-dim); color: #fff; }
|
||||
button.ghost { background: transparent; color: var(--fg); border: 1px solid var(--border); }
|
||||
button.ghost:hover { background: var(--bg-panel-2); color: var(--fg); }
|
||||
button.danger { background: transparent; color: var(--err); border: 1px solid #4a1f1f; }
|
||||
button.danger:hover { background: #2a0b0b; color: var(--err); }
|
||||
|
||||
label.field {
|
||||
display: grid; gap: 4px; margin-bottom: 14px;
|
||||
}
|
||||
label.field .name { color: var(--fg-dim); font-size: 12px; }
|
||||
label.field .hint { color: var(--fg-dimmer); font-size: 11px; }
|
||||
label.field input[type="text"],
|
||||
label.field input[type="number"],
|
||||
label.field select,
|
||||
label.field textarea {
|
||||
width: 100%; background: var(--bg); color: var(--fg);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 7px 10px; font: inherit;
|
||||
}
|
||||
label.field input:focus, label.field select:focus, label.field textarea:focus {
|
||||
outline: none; border-color: var(--accent);
|
||||
}
|
||||
label.check {
|
||||
display: flex; gap: 10px; align-items: center;
|
||||
padding: 8px 10px; margin-bottom: 6px;
|
||||
border: 1px solid var(--border-soft); border-radius: var(--radius);
|
||||
}
|
||||
label.check input { accent-color: var(--accent); }
|
||||
|
||||
/* ── Drop zone ────────────────────────────────────────────────────── */
|
||||
|
||||
.drop {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 32px; text-align: center;
|
||||
color: var(--fg-dim); cursor: pointer;
|
||||
transition: border-color .15s, color .15s, background .15s;
|
||||
}
|
||||
.drop.hover, .drop:hover {
|
||||
border-color: var(--accent); color: var(--fg);
|
||||
background: var(--bg-panel-2);
|
||||
}
|
||||
.drop strong { color: var(--accent); }
|
||||
.progress { height: 6px; background: var(--bg-panel-2); border-radius: 3px; overflow: hidden; margin-top: 12px; display: none; }
|
||||
.progress.active { display: block; }
|
||||
.progress .bar { height: 100%; width: 0%; background: var(--accent); transition: width .25s; }
|
||||
|
||||
/* ── Gate queue "horse race" visual ───────────────────────────────── */
|
||||
|
||||
.gate-track {
|
||||
display: grid; gap: 6px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.gate-row {
|
||||
display: grid; grid-template-columns: 32px 1fr auto auto; align-items: center;
|
||||
gap: 14px;
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-panel-2); border-radius: var(--radius);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
.gate-row.assigned { border-left-color: var(--ok); }
|
||||
.gate-row .pos { font-family: var(--mono); font-size: 15px; color: var(--accent); font-weight: 600; }
|
||||
.gate-row.assigned .pos { color: var(--ok); }
|
||||
.gate-row .mac { font-family: var(--mono); font-size: 13px; }
|
||||
.gate-row .meta { color: var(--fg-dim); font-size: 12px; }
|
||||
|
||||
.empty { color: var(--fg-dim); padding: 30px; text-align: center; }
|
||||
.msg { color: var(--fg-dim); font-size: 12.5px; margin-top: 8px; }
|
||||
.msg.err { color: var(--err); }
|
||||
.msg.ok { color: var(--ok); }
|
||||
|
||||
/* ── Top bar readiness chip ──────────────────────────────────────── */
|
||||
.chip.ready { background: #103428; color: var(--ok); border-color: #1a4f3c; }
|
||||
.chip.notready { background: #3a1515; color: var(--err); border-color: #5a1f1f; }
|
||||
.chip.warming { background: #3a2a10; color: var(--warn); border-color: #4a3a18; }
|
||||
|
||||
/* ── Dashboard stat strip ────────────────────────────────────────── */
|
||||
.statstrip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
|
||||
@media (max-width: 1100px) { .statstrip { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
.kv { display: grid; grid-template-columns: 160px 1fr; gap: 6px 14px;
|
||||
padding: 4px 0; font-size: 13px; }
|
||||
.kv .k { color: var(--fg-dim); }
|
||||
.kv .v { font-family: var(--mono); color: var(--fg); word-break: break-all; }
|
||||
.kv .v.warn { color: var(--warn); }
|
||||
.kv .v.err { color: var(--err); }
|
||||
.kv .v.ok { color: var(--ok); }
|
||||
|
||||
/* ── Image rows: amber tint on un-bootable images (Bootimus pattern) ── */
|
||||
tr.unbootable td { background: rgba(255, 179, 71, 0.07) !important; }
|
||||
tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
|
||||
.row-warn { color: var(--warn); font-size: 11.5px; margin-top: 2px; }
|
||||
|
||||
/* ── Table source badge ──────────────────────────────────────────── */
|
||||
.src-badge { font-family: var(--mono); font-size: 11px; padding: 1px 6px;
|
||||
border-radius: 4px; background: var(--bg-elev); color: var(--fg-dim); }
|
||||
.src-badge.nfs { background: #122a3a; color: #7cd3ff; }
|
||||
|
||||
/* ── NFS modal-ish add form ──────────────────────────────────────── */
|
||||
.nfs-row { display: grid; grid-template-columns: 32px 1fr auto auto auto; align-items: center;
|
||||
gap: 14px; padding: 10px 14px; background: var(--bg-panel-2);
|
||||
border-left: 3px solid var(--accent); border-radius: var(--radius); }
|
||||
.nfs-row.down { border-left-color: var(--err); }
|
||||
.nfs-row .id { font-family: var(--mono); font-size: 12.5px; color: var(--fg); }
|
||||
.nfs-row .meta { color: var(--fg-dim); font-size: 12px; }
|
||||
.nfs-row .err { color: var(--err); font-size: 11.5px; word-break: break-all; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.dot.ok { background: var(--ok); }
|
||||
.dot.err { background: var(--err); }
|
||||
.dot.warn { background: var(--warn); }
|
||||
|
||||
/* ── Inline form rows (used by Network + NFS add) ───────────────── */
|
||||
.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; }
|
||||
@media (max-width: 900px) { .form-row { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ── Terminal pane ──────────────────────────────────────────────── */
|
||||
.terminal {
|
||||
display: flex; flex-direction: column;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: #06090e;
|
||||
overflow: hidden;
|
||||
height: calc(100vh - var(--topbar-h) - 90px);
|
||||
min-height: 480px;
|
||||
}
|
||||
.terminal .pane {
|
||||
flex: 1; overflow: auto;
|
||||
padding: 10px 14px;
|
||||
font-family: var(--mono); font-size: 12.5px; line-height: 1.5;
|
||||
color: #cfd6e2;
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
.terminal .pane .lvl-error { color: var(--err); }
|
||||
.terminal .pane .lvl-warn { color: var(--warn); }
|
||||
.terminal .pane .lvl-info { color: #cfd6e2; }
|
||||
.terminal .pane .lvl-debug { color: var(--fg-dim); }
|
||||
.terminal .pane .lvl-trace { color: var(--fg-dimmer); }
|
||||
.terminal .pane .ts { color: var(--fg-dimmer); }
|
||||
.terminal .pane .tg { color: #7cd3ff; }
|
||||
.terminal .pane .echo { color: var(--accent); }
|
||||
.terminal .input-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: #0a0e15;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.terminal .input-row .prompt { color: var(--accent); font-family: var(--mono); }
|
||||
.terminal .input-row input {
|
||||
flex: 1; background: transparent; border: 0; color: var(--fg);
|
||||
font: inherit; font-family: var(--mono); font-size: 13px;
|
||||
outline: none; padding: 4px 0;
|
||||
}
|
||||
.terminal .toolbar {
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-panel-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12px; color: var(--fg-dim);
|
||||
}
|
||||
.terminal .toolbar .right { margin-left: auto; display: flex; gap: 6px; }
|
||||
.terminal .toolbar button {
|
||||
padding: 3px 9px; font-size: 11px;
|
||||
background: transparent; color: var(--fg-dim); border: 1px solid var(--border);
|
||||
font-weight: 500;
|
||||
}
|
||||
.terminal .toolbar button:hover { color: var(--fg); background: var(--bg-elev); }
|
||||
|
||||
/* ── About card ─────────────────────────────────────────────────── */
|
||||
.about-hero { padding: 20px 24px; }
|
||||
.about-hero h2 { font-size: 22px; margin: 0 0 8px; color: var(--fg); }
|
||||
.about-hero .lead { color: var(--fg-dim); font-size: 14px; max-width: 60ch; }
|
||||
.about-hero .who { margin-top: 18px; font-size: 13px; }
|
||||
.about-hero .who span { color: var(--fg-dim); }
|
||||
.about-hero .who strong { color: var(--accent); }
|
||||
@@ -0,0 +1,696 @@
|
||||
// PXEForge web UI — vanilla JS, no build step, no framework, no network
|
||||
// dependencies. Uses fetch() + EventSource only.
|
||||
//
|
||||
// Tabs (Phase 4): Dashboard / Network / Forge Gate / Storage / Terminal /
|
||||
// About. The shell swaps a single view into #view-root.
|
||||
//
|
||||
// Keep this readable — nobody wants to debug a clever vanilla-JS
|
||||
// framework at 3 AM. Plain dumb DOM construction is the design.
|
||||
|
||||
(function () {
|
||||
const $ = (sel, root = document) => root.querySelector(sel);
|
||||
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
||||
const el = (tag, attrs = {}, children = []) => {
|
||||
const e = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k === 'class') e.className = v;
|
||||
else if (k === 'html') e.innerHTML = v;
|
||||
else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2), v);
|
||||
else if (v !== false && v != null) e.setAttribute(k, v);
|
||||
}
|
||||
for (const c of [].concat(children)) {
|
||||
if (c == null || c === false) continue;
|
||||
if (typeof c === 'string') e.appendChild(document.createTextNode(c));
|
||||
else e.appendChild(c);
|
||||
}
|
||||
return e;
|
||||
};
|
||||
const fmtBytes = (n) => {
|
||||
const u = ['B','KB','MB','GB','TB']; let i = 0;
|
||||
while (n >= 1024 && i < u.length-1) { n /= 1024; i++; }
|
||||
return n.toFixed(n >= 10 || i === 0 ? 0 : 1) + ' ' + u[i];
|
||||
};
|
||||
const fmtAgo = (ts) => {
|
||||
const d = (ts instanceof Date) ? ts : new Date(ts);
|
||||
if (isNaN(d.getTime())) return '-';
|
||||
const ds = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
if (ds < 0) return 'in ' + Math.abs(ds) + 's';
|
||||
if (ds < 60) return ds + 's ago';
|
||||
if (ds < 3600) return Math.floor(ds/60) + 'm ago';
|
||||
if (ds < 86400) return Math.floor(ds/3600) + 'h ago';
|
||||
return d.toLocaleString();
|
||||
};
|
||||
const fmtUptime = (secs) => {
|
||||
secs = Math.max(0, Math.floor(secs || 0));
|
||||
const h = Math.floor(secs/3600), m = Math.floor((secs%3600)/60), s = secs%60;
|
||||
if (h) return h + 'h ' + m + 'm';
|
||||
if (m) return m + 'm ' + s + 's';
|
||||
return s + 's';
|
||||
};
|
||||
const familyLabel = (f) => ({
|
||||
debian_ubuntu: 'Debian / Ubuntu', rhel_fedora: 'RHEL family',
|
||||
open_suse: 'openSUSE', arch: 'Arch', alpine: 'Alpine',
|
||||
windows_pe: 'Windows', unknown: 'Unknown',
|
||||
}[f] || f);
|
||||
const archLabel = (a) => {
|
||||
if (!a) return '—';
|
||||
if (typeof a === 'string') return a;
|
||||
if (a && typeof a === 'object') {
|
||||
if ('Unknown' in a) return 'unknown(0x' + a.Unknown.toString(16) + ')';
|
||||
return JSON.stringify(a);
|
||||
}
|
||||
return String(a);
|
||||
};
|
||||
|
||||
// ── network helpers ──────────────────────────────────────────────
|
||||
async function getJSON(url) {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(url + ': ' + r.status);
|
||||
return r.json();
|
||||
}
|
||||
async function putJSON(url, body) {
|
||||
return fetch(url, {method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
|
||||
}
|
||||
async function postJSON(url, body) {
|
||||
return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
|
||||
}
|
||||
|
||||
// Categorize an ISO row's "bootable now" status — drives the amber
|
||||
// tint borrowed from Bootimus v0.1.62. Returns {ok, reason}.
|
||||
function bootability(iso, settings) {
|
||||
const fam = iso.introspection.family;
|
||||
const isWin = fam === 'windows_pe';
|
||||
if (isWin && !settings.windows_enabled) {
|
||||
return { ok: false, reason: 'Windows boot disabled in Settings' };
|
||||
}
|
||||
if (!isWin && !iso.introspection.kernel_path && fam !== 'windows_pe') {
|
||||
// Linux without a detected kernel falls through to sanboot which
|
||||
// rarely works for >1 GiB ISOs.
|
||||
if (iso.size_bytes > 1.5 * 1024 * 1024 * 1024) {
|
||||
return { ok: false, reason: 'no kernel/initrd detected; ISO too large for sanboot fallback' };
|
||||
}
|
||||
return { ok: true, warn: 'no kernel detected — sanboot fallback may not work' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── views ────────────────────────────────────────────────────────
|
||||
const views = {
|
||||
dashboard: async () => {
|
||||
const status = await getJSON('/api/status');
|
||||
const isos = await getJSON('/api/isos');
|
||||
const clients = (await getJSON('/api/clients')).clients || [];
|
||||
const gates = (await getJSON('/api/gate')).gates || [];
|
||||
|
||||
const ipxeOk = (status.ipxe_assets || []).length > 0;
|
||||
const stats = el('div', {class: 'statstrip'}, [
|
||||
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
||||
el('div', {class: 'label'}, 'Server status'),
|
||||
el('div', {class: 'value', style: 'font-size:18px;color:' + (ipxeOk ? 'var(--ok)' : 'var(--err)')},
|
||||
ipxeOk ? 'Ready' : 'Not ready'),
|
||||
el('div', {class: 'trend'},
|
||||
ipxeOk ? 'Bootloaders bundled, accepting clients'
|
||||
: 'No iPXE binaries bundled'),
|
||||
])),
|
||||
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
||||
el('div', {class: 'label'}, 'Imaging now'),
|
||||
el('div', {class: 'value'}, String(status.imaging_count || 0)),
|
||||
el('div', {class: 'trend'}, (status.waiting_count || 0) + ' waiting at gate'),
|
||||
])),
|
||||
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
||||
el('div', {class: 'label'}, 'Images available'),
|
||||
el('div', {class: 'value'}, String(isos.length)),
|
||||
el('div', {class: 'trend'},
|
||||
isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' +
|
||||
isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux · ' +
|
||||
(status.nfs_active || 0) + ' NFS active'),
|
||||
])),
|
||||
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
||||
el('div', {class: 'label'}, 'Uptime'),
|
||||
el('div', {class: 'value', style: 'font-size:22px'}, fmtUptime(status.uptime_secs)),
|
||||
el('div', {class: 'trend'}, 'PXEForge ' + status.version),
|
||||
])),
|
||||
]);
|
||||
|
||||
// Recent connections — the operator's at-a-glance "who tried to
|
||||
// boot" log. Use last_seen desc (already sorted by API).
|
||||
const recent = clients.slice(0, 8);
|
||||
const recentRows = recent.map(c => {
|
||||
const g = gates.find(g => g.mac === c.mac);
|
||||
let status = el('span', {class: 'tag ok'}, 'active');
|
||||
if (g && g.assigned_target) status = el('span', {class:'tag ok'}, 'assigned: ' + g.assigned_target);
|
||||
else if (g) status = el('span', {class:'tag accent'}, '#' + g.position + ' at gate');
|
||||
return el('tr', {}, [
|
||||
el('td', {class: 'mono'}, c.mac),
|
||||
el('td', {}, c.last_ip ? String(c.last_ip) : '-'),
|
||||
el('td', {}, el('span', {class:'tag arch'}, archLabel(c.arch))),
|
||||
el('td', {}, fmtAgo(c.last_seen)),
|
||||
el('td', {}, status),
|
||||
]);
|
||||
});
|
||||
const recentBlock = el('div', {class: 'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'Recent connections'),
|
||||
el('span', {class: 'sub'}, clients.length + ' total'),
|
||||
]),
|
||||
recent.length
|
||||
? el('table', {}, [
|
||||
el('thead', {}, el('tr', {}, [
|
||||
el('th',{},'MAC'), el('th',{},'IP'), el('th',{},'Arch'),
|
||||
el('th',{},'Last seen'), el('th',{},'Status'),
|
||||
])),
|
||||
el('tbody', {}, recentRows),
|
||||
])
|
||||
: el('div', {class: 'empty'}, 'No PXE clients have contacted this server yet.'),
|
||||
]);
|
||||
|
||||
// At-a-glance pool of problem images — Bootimus-style early warning.
|
||||
const settings = status.settings;
|
||||
const problems = isos.map(i => ({i, b: bootability(i, settings)})).filter(x => !x.b.ok);
|
||||
const problemsBlock = problems.length ? el('div', {class:'card'}, [
|
||||
el('header', {}, [el('h2', {}, 'Images that won\'t boot with current settings')]),
|
||||
el('div', {class:'body'},
|
||||
problems.map(({i, b}) => el('div', {class:'row-warn'},
|
||||
'⚠ ' + i.filename + ' — ' + b.reason)))
|
||||
]) : null;
|
||||
|
||||
return el('div', {class:'grid'}, [stats, recentBlock, problemsBlock].filter(Boolean));
|
||||
},
|
||||
|
||||
network: async () => {
|
||||
const net = await getJSON('/api/network');
|
||||
const dns = el('input', {type:'text', value: net.dns_server || '',
|
||||
placeholder: 'Optional, e.g. 8.8.8.8 or 1.1.1.1'});
|
||||
const msg = el('div', {class:'msg'});
|
||||
|
||||
const save = el('button', {onclick: async () => {
|
||||
const r = await putJSON('/api/network', { dns_server: dns.value });
|
||||
if (r.status === 204) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; }
|
||||
else { msg.textContent = 'Save failed: ' + r.status; msg.className = 'msg err'; }
|
||||
}}, 'Save');
|
||||
|
||||
const networkCard = el('div', {class:'card'}, [
|
||||
el('header', {}, el('h2', {}, 'Network')),
|
||||
el('div', {class:'body'}, [
|
||||
el('div', {class:'kv'}, [
|
||||
el('div', {class:'k'}, 'Server IP'),
|
||||
el('div', {class:'v'}, net.server_ip || '?'),
|
||||
el('div', {class:'k'}, 'NIC name'),
|
||||
el('div', {class:'v'}, net.nic_name || '(auto-detect failed)'),
|
||||
el('div', {class:'k'}, 'Subnet mask'),
|
||||
el('div', {class:'v'}, net.subnet_mask || '?'),
|
||||
el('div', {class:'k'}, 'Gateway'),
|
||||
el('div', {class:'v'}, net.gateway || '?'),
|
||||
el('div', {class:'k'}, 'Public base URL'),
|
||||
el('div', {class:'v'}, net.public_base_url),
|
||||
]),
|
||||
el('p', {class:'msg'},
|
||||
'Server IP, NIC, mask, and gateway are auto-detected at startup. ' +
|
||||
'To change them, set PXEFORGE_PUBLIC_IP and restart — editing them ' +
|
||||
'from a hot UI would silently break PXE for every client mid-boot.'),
|
||||
el('label', {class:'field', style:'margin-top:18px'}, [
|
||||
el('span', {class:'name'}, 'DNS server (optional, informational)'),
|
||||
dns,
|
||||
el('span', {class:'hint'},
|
||||
'PXEForge does not run a DNS server itself; this field records ' +
|
||||
'what your upstream DNS is so you don\'t have to dig it out at ' +
|
||||
'3 AM during a deployment.'),
|
||||
]),
|
||||
save, msg,
|
||||
]),
|
||||
]);
|
||||
|
||||
return el('div', {class:'grid'}, [networkCard]);
|
||||
},
|
||||
|
||||
gate: async () => {
|
||||
const [{ gates = [] }, isos] = await Promise.all([
|
||||
getJSON('/api/gate'), getJSON('/api/isos'),
|
||||
]);
|
||||
const targets = isos.flatMap(i => i.boot_entries.map(e => ({
|
||||
id: e.id, title: e.title + ' — ' + familyLabel(i.introspection.family)
|
||||
})));
|
||||
const pick = el('select', {},
|
||||
[el('option', {value: ''}, '— choose an image —')]
|
||||
.concat(targets.map(t => el('option', {value: t.id}, t.title)))
|
||||
);
|
||||
const launch = el('button', {}, 'Launch for all waiting');
|
||||
const msg = el('div', {class:'msg'});
|
||||
launch.onclick = async () => {
|
||||
if (!pick.value) { msg.textContent = 'Pick an image first.'; msg.className='msg err'; return; }
|
||||
const r = await postJSON('/api/gate/assign', { target: pick.value, gate_ids: [] });
|
||||
if (!r.ok) { msg.textContent = 'Assign failed: ' + r.status; msg.className='msg err'; return; }
|
||||
const j = await r.json();
|
||||
if (!j.ok) { msg.textContent = 'Assign failed: ' + (j.error || 'unknown'); msg.className='msg err'; return; }
|
||||
msg.textContent = 'Launched ' + j.assigned + ' client' + (j.assigned===1?'':'s') + ' → ' + j.target;
|
||||
msg.className = 'msg ok';
|
||||
render('gate');
|
||||
};
|
||||
|
||||
const track = gates.length
|
||||
? el('div', {class:'gate-track'},
|
||||
gates.map(g => el('div', {class:'gate-row' + (g.assigned_target ? ' assigned' : '')}, [
|
||||
el('div', {class:'pos'}, '#' + g.position),
|
||||
el('div', {}, [
|
||||
el('div', {class:'mac'}, g.mac),
|
||||
el('div', {class:'meta'},
|
||||
(g.ip ? String(g.ip) + ' · ' : '') + archLabel(g.arch) + ' · joined ' + fmtAgo(g.joined_at)),
|
||||
]),
|
||||
el('div', {}, g.assigned_target
|
||||
? el('span', {class:'tag ok'}, '→ ' + g.assigned_target)
|
||||
: el('span', {class:'tag accent'}, 'waiting')),
|
||||
el('button', {class:'ghost', onclick: async () => {
|
||||
await fetch('/api/gate/' + encodeURIComponent(g.id), {method:'DELETE'});
|
||||
render('gate');
|
||||
}}, 'Release'),
|
||||
]))
|
||||
)
|
||||
: el('div', {class:'empty'},
|
||||
'No clients at the gate. Boot a client and choose "Gated Deployment" in the PXE menu.');
|
||||
|
||||
return el('div', {class:'grid'}, [
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, el('h2', {}, 'Launch an image across the gate')),
|
||||
el('div', {class:'body'}, [
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'Target image'),
|
||||
pick,
|
||||
el('span', {class:'hint'},
|
||||
'Selecting "Launch" starts every waiting client on the chosen image simultaneously.'),
|
||||
]),
|
||||
launch, msg,
|
||||
]),
|
||||
]),
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'Gate positions'),
|
||||
el('span', {class:'sub'}, gates.length + ' waiting'),
|
||||
]),
|
||||
el('div', {class:'body'}, track),
|
||||
]),
|
||||
]);
|
||||
},
|
||||
|
||||
storage: async () => {
|
||||
const [isos, settings, nfsRes] = await Promise.all([
|
||||
getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/nfs'),
|
||||
]);
|
||||
const mounts = nfsRes.mounts || [];
|
||||
|
||||
// ── Upload card ──
|
||||
const drop = el('div', {class:'drop', id:'drop'}, [
|
||||
el('div', {}, ['Drop an ', el('strong', {}, '.iso'), ' here, or click to choose.']),
|
||||
el('div', {style:'font-size:12px;margin-top:6px'},
|
||||
'Linux + Windows installers auto-detected on upload. Streaming, no 502s on big files.'),
|
||||
]);
|
||||
const file = el('input', {type:'file', accept:'.iso,application/octet-stream',
|
||||
style:'display:none', id:'file'});
|
||||
const prog = el('div', {class:'progress', id:'prog'}, el('div', {class:'bar', id:'bar'}));
|
||||
const upMsg = el('div', {class:'msg', id:'upmsg'});
|
||||
|
||||
drop.onclick = () => file.click();
|
||||
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('hover'); });
|
||||
drop.addEventListener('dragleave', () => drop.classList.remove('hover'));
|
||||
drop.addEventListener('drop', e => {
|
||||
e.preventDefault(); drop.classList.remove('hover');
|
||||
if (e.dataTransfer.files[0]) upload(e.dataTransfer.files[0]);
|
||||
});
|
||||
file.onchange = () => { if (file.files[0]) upload(file.files[0]); };
|
||||
|
||||
function upload(f) {
|
||||
upMsg.textContent = 'Uploading ' + f.name + ' (' + fmtBytes(f.size) + ')…';
|
||||
upMsg.className = 'msg';
|
||||
prog.classList.add('active');
|
||||
const fd = new FormData(); fd.append('file', f);
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.upload.onprogress = e => {
|
||||
if (e.lengthComputable) $('#bar').style.width = (e.loaded/e.total*100).toFixed(1) + '%';
|
||||
};
|
||||
xhr.onload = () => {
|
||||
prog.classList.remove('active');
|
||||
$('#bar').style.width = '0';
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
upMsg.textContent = 'Uploaded & analyzed.'; upMsg.className = 'msg ok';
|
||||
render('storage');
|
||||
} else {
|
||||
upMsg.textContent = 'Upload failed: ' + xhr.status + ' ' + xhr.responseText;
|
||||
upMsg.className = 'msg err';
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => { upMsg.textContent = 'Network error.'; upMsg.className = 'msg err'; };
|
||||
xhr.open('POST', '/api/isos');
|
||||
xhr.send(fd);
|
||||
}
|
||||
|
||||
// ── ISO table (mixed local + NFS) ──
|
||||
const rows = isos.map(i => {
|
||||
const b = bootability(i, settings);
|
||||
const isNfs = i.source && i.source.kind === 'nfs';
|
||||
const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [
|
||||
el('td', {}, [
|
||||
el('div', {}, i.filename),
|
||||
!b.ok ? el('div', {class:'row-warn'}, '⚠ ' + b.reason)
|
||||
: (b.warn ? el('div', {class:'row-warn'}, '⚠ ' + b.warn) : null),
|
||||
]),
|
||||
el('td', {}, el('span', {class:'tag'}, familyLabel(i.introspection.family))),
|
||||
el('td', {class:'num'}, fmtBytes(i.size_bytes)),
|
||||
el('td', {},
|
||||
el('span', {class:'src-badge' + (isNfs ? ' nfs' : '')},
|
||||
isNfs ? ('nfs:' + i.source.mount_id) : 'local')),
|
||||
el('td', {}, fmtAgo(i.uploaded_at)),
|
||||
el('td', {style:'text-align:right'},
|
||||
isNfs
|
||||
? el('span', {class:'tag', style:'opacity:.6'}, 'manage on NFS share')
|
||||
: el('button', {class:'danger', onclick: async () => {
|
||||
if (!confirm('Remove this image?')) return;
|
||||
await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'});
|
||||
render('storage');
|
||||
}}, 'Remove')),
|
||||
]);
|
||||
return tr;
|
||||
});
|
||||
const isoTable = isos.length
|
||||
? el('table', {}, [
|
||||
el('thead', {}, el('tr', {}, [
|
||||
el('th',{},'Name'), el('th',{},'Type'),
|
||||
el('th',{class:'num'},'Size'),
|
||||
el('th',{},'Source'), el('th',{},'Uploaded'), el('th',{},''),
|
||||
])),
|
||||
el('tbody', {}, rows),
|
||||
])
|
||||
: el('div', {class:'empty'}, 'No images yet. Upload an ISO or mount an NFS share.');
|
||||
|
||||
// ── NFS section ──
|
||||
const nfsMsg = el('div', {class:'msg'});
|
||||
const nfsServer = el('input', {type:'text', placeholder:'10.0.0.20'});
|
||||
const nfsExport = el('input', {type:'text', placeholder:'/srv/isos'});
|
||||
const nfsVer = el('select', {}, [
|
||||
el('option', {value:'v41'}, 'NFSv4.1 (default)'),
|
||||
el('option', {value:'v3'}, 'NFSv3'),
|
||||
]);
|
||||
const nfsRo = el('input', {type:'checkbox'}); nfsRo.checked = true;
|
||||
const addNfs = el('button', {onclick: async () => {
|
||||
if (!nfsServer.value || !nfsExport.value) {
|
||||
nfsMsg.textContent = 'Server and export are required.'; nfsMsg.className='msg err'; return;
|
||||
}
|
||||
nfsMsg.textContent = 'Mounting…'; nfsMsg.className = 'msg';
|
||||
const r = await postJSON('/api/nfs', {
|
||||
server: nfsServer.value, export: nfsExport.value,
|
||||
version: nfsVer.value, read_only: nfsRo.checked,
|
||||
});
|
||||
if (r.ok) {
|
||||
nfsMsg.textContent = 'Mounted.'; nfsMsg.className = 'msg ok';
|
||||
render('storage');
|
||||
} else {
|
||||
const t = await r.text();
|
||||
nfsMsg.textContent = 'Mount failed: ' + t; nfsMsg.className = 'msg err';
|
||||
}
|
||||
}}, 'Mount share');
|
||||
|
||||
const nfsRows = mounts.length ? mounts.map(m => el('div', {class: 'nfs-row' + (m.mounted ? '' : ' down')}, [
|
||||
el('span', {class: 'dot ' + (m.mounted ? 'ok' : 'err')}),
|
||||
el('div', {}, [
|
||||
el('div', {class:'id'}, m.server + ':' + m.export),
|
||||
el('div', {class:'meta'},
|
||||
(m.version === 'v3' ? 'NFSv3' : 'NFSv4.1') + ' · ' +
|
||||
(m.read_only ? 'read-only' : 'read-write') + ' · ' +
|
||||
(m.mounted ? m.iso_count + ' isos' : 'not mounted')),
|
||||
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
|
||||
]),
|
||||
el('button', {class:'ghost', onclick: async () => {
|
||||
const r = await postJSON('/api/nfs/' + encodeURIComponent(m.id) + '/scan', {});
|
||||
if (r.ok) render('storage');
|
||||
}}, 'Re-scan'),
|
||||
el('button', {class:'danger', onclick: async () => {
|
||||
if (!confirm('Unmount ' + m.server + ':' + m.export + '?')) return;
|
||||
await fetch('/api/nfs/' + encodeURIComponent(m.id), {method:'DELETE'});
|
||||
render('storage');
|
||||
}}, 'Unmount'),
|
||||
el('span'),
|
||||
])) : [el('div', {class:'empty'}, 'No NFS shares mounted.')];
|
||||
|
||||
return el('div', {class:'grid'}, [
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, el('h2', {}, 'Upload ISO')),
|
||||
el('div', {class:'body'}, [drop, file, prog, upMsg]),
|
||||
]),
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'NFS shares'),
|
||||
el('span', {class:'sub'}, mounts.length + ' configured'),
|
||||
]),
|
||||
el('div', {class:'body'}, [
|
||||
el('div', {class:'form-row'}, [
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'NFS server'),
|
||||
nfsServer,
|
||||
]),
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'Export path'),
|
||||
nfsExport,
|
||||
]),
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'Version'),
|
||||
nfsVer,
|
||||
]),
|
||||
el('label', {class:'check', style:'margin-top:18px'}, [
|
||||
nfsRo, el('span', {}, 'Read-only'),
|
||||
]),
|
||||
]),
|
||||
addNfs, nfsMsg,
|
||||
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, nfsRows),
|
||||
el('p', {class:'msg', style:'margin-top:14px'},
|
||||
'Mounting NFS inside a container requires CAP_SYS_ADMIN and the ' +
|
||||
'mount.nfs binary (bundled in the default Docker image). On ' +
|
||||
'OpenShift, your SCC must allow CAP_SYS_ADMIN or you can run ' +
|
||||
'NFS mounts as a CSI driver outside the pod.'),
|
||||
]),
|
||||
]),
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'Available images'),
|
||||
el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')),
|
||||
]),
|
||||
isoTable,
|
||||
]),
|
||||
]);
|
||||
},
|
||||
|
||||
terminal: async () => {
|
||||
// Two-pane layout: live log on top (auto-scrolling), command line
|
||||
// on bottom. Mirrors the Minecraft-server console feel from the
|
||||
// brief — output and input share one continuous timeline.
|
||||
const pane = el('div', {class:'pane'});
|
||||
const input = el('input', {type:'text', placeholder:'type a command, or "help"', spellcheck:'false', autocapitalize:'off', autocomplete:'off'});
|
||||
const auto = el('input', {type:'checkbox'}); auto.checked = true;
|
||||
const clearBtn = el('button', {onclick: () => { pane.innerHTML = ''; }}, 'Clear pane');
|
||||
const tailBtn = el('button', {onclick: () => { auto.checked = !auto.checked; }}, 'Auto-scroll');
|
||||
|
||||
const term = el('div', {class:'terminal'}, [
|
||||
el('div', {class:'toolbar'}, [
|
||||
el('span', {}, 'Live log + operator console'),
|
||||
el('div', {class:'right'}, [
|
||||
el('label', {class:'check', style:'border:0;padding:0;margin:0;background:transparent'},
|
||||
[auto, el('span', {style:'color:var(--fg-dim);font-size:11px'}, 'Auto-scroll')]),
|
||||
clearBtn,
|
||||
]),
|
||||
]),
|
||||
pane,
|
||||
el('div', {class:'input-row'}, [
|
||||
el('span', {class:'prompt'}, '>'),
|
||||
input,
|
||||
]),
|
||||
]);
|
||||
|
||||
function append(line, kind) {
|
||||
const lvl = (line.level || 'info').toLowerCase();
|
||||
const ts = (line.timestamp || new Date().toISOString()).replace(/\.\d+/, '').replace('T', ' ').replace('Z', '');
|
||||
const span = el('span', {class: 'lvl-' + lvl}, [
|
||||
el('span', {class:'ts'}, ts + ' '),
|
||||
el('span', {class:'tg'}, '[' + (line.target || 'pxeforge') + '] '),
|
||||
line.message,
|
||||
'\n',
|
||||
]);
|
||||
if (kind === 'echo') {
|
||||
span.firstChild.nextSibling.textContent = '';
|
||||
span.firstChild.textContent = '';
|
||||
span.classList.add('echo');
|
||||
}
|
||||
pane.appendChild(span);
|
||||
if (auto.checked) pane.scrollTop = pane.scrollHeight;
|
||||
}
|
||||
|
||||
// Initial fetch — show recent buffer in case SSE is slow to open.
|
||||
try {
|
||||
const r = await getJSON('/api/log/recent');
|
||||
for (const l of (r.lines || [])) append(l);
|
||||
} catch (e) {
|
||||
append({timestamp: new Date().toISOString(), level:'warn', target:'pxeforge::ui',
|
||||
message: 'failed to load recent logs: ' + e.message});
|
||||
}
|
||||
|
||||
// Live SSE stream. EventSource auto-reconnects on disconnect.
|
||||
const es = new EventSource('/api/log/stream');
|
||||
es.onmessage = (ev) => {
|
||||
try { append(JSON.parse(ev.data)); }
|
||||
catch { append({timestamp: new Date().toISOString(), level:'debug', target:'pxeforge::ui', message: ev.data}); }
|
||||
};
|
||||
es.addEventListener('lagged', (ev) => {
|
||||
const j = JSON.parse(ev.data || '{}');
|
||||
append({timestamp: new Date().toISOString(), level:'warn',
|
||||
target:'pxeforge::ui',
|
||||
message: 'log stream lagged: ' + (j.skipped || '?') + ' lines skipped'});
|
||||
});
|
||||
es.onerror = () => {
|
||||
// EventSource quietly retries; surface a hint without spamming.
|
||||
// We only append once, on transition from connected → erroring.
|
||||
if (!term._notedErr) {
|
||||
term._notedErr = true;
|
||||
append({timestamp: new Date().toISOString(), level:'warn', target:'pxeforge::ui',
|
||||
message: 'log stream connection lost — auto-reconnecting'});
|
||||
setTimeout(() => { term._notedErr = false; }, 5000);
|
||||
}
|
||||
};
|
||||
// Close the SSE when the view changes — avoids piling up streams.
|
||||
term._cleanup = () => es.close();
|
||||
|
||||
// Command history (in-memory only, ↑/↓ to recall).
|
||||
const history = [];
|
||||
let hi = -1;
|
||||
|
||||
input.addEventListener('keydown', async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const cmd = input.value;
|
||||
if (!cmd.trim()) return;
|
||||
input.value = '';
|
||||
history.unshift(cmd); if (history.length > 100) history.pop();
|
||||
hi = -1;
|
||||
// The echo also comes back from the server in the live tail,
|
||||
// so we don't append it locally — keeps the order consistent.
|
||||
try {
|
||||
const r = await postJSON('/api/terminal', {command: cmd});
|
||||
const j = await r.json();
|
||||
const out = j.output || '';
|
||||
if (out === '\f') { pane.innerHTML = ''; return; }
|
||||
// Output also gets pushed onto the LogBus by the server, but
|
||||
// include it locally so even if the SSE stream dropped we
|
||||
// see it. Tagged "echo" so it stands out from regular logs.
|
||||
append({timestamp: new Date().toISOString(), level: j.ok ? 'info' : 'warn',
|
||||
target: 'terminal-output', message: out});
|
||||
} catch (err) {
|
||||
append({timestamp: new Date().toISOString(), level:'error',
|
||||
target:'pxeforge::ui', message: 'command failed: ' + err.message});
|
||||
}
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
if (history.length === 0) return;
|
||||
hi = Math.min(hi + 1, history.length - 1);
|
||||
input.value = history[hi];
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
hi = Math.max(hi - 1, -1);
|
||||
input.value = hi < 0 ? '' : history[hi];
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Welcome banner.
|
||||
append({timestamp: new Date().toISOString(), level:'info', target:'pxeforge::terminal',
|
||||
message: 'Connected. Type "help" for available commands.'});
|
||||
|
||||
// Focus the input on next tick (after view swap completes).
|
||||
setTimeout(() => input.focus(), 50);
|
||||
|
||||
return term;
|
||||
},
|
||||
|
||||
about: async () => {
|
||||
const status = await getJSON('/api/status');
|
||||
return el('div', {class:'card'}, [
|
||||
el('div', {class:'about-hero'}, [
|
||||
el('h2', {}, 'PXEForge'),
|
||||
el('p', {class:'lead'},
|
||||
'Air-gapped network PXE boot, container-native, that anyone can run. ' +
|
||||
'No CDN calls, no telemetry, no surprise external dependencies — ship ' +
|
||||
'the image once, run it forever.'),
|
||||
el('div', {class:'who'}, [
|
||||
el('span', {}, 'Developer: '), el('strong', {}, 'Miles Ward'), el('br'),
|
||||
el('span', {}, 'Version: '), el('strong', {}, status.version || '?'), el('br'),
|
||||
el('span', {}, 'Base URL: '), el('strong', {}, status.public_base_url),
|
||||
]),
|
||||
el('p', {class:'msg', style:'margin-top:18px'},
|
||||
'iPXE is an internal implementation detail. Everything the firmware ' +
|
||||
'executes is generated from the settings on these tabs — there is no ' +
|
||||
'hand-written .ipxe path anywhere in this product.'),
|
||||
el('p', {class:'msg'},
|
||||
'Vision: a deployment-grade tool that works on first try in the most ' +
|
||||
'awkward environments — air-gapped labs, customer sites without ' +
|
||||
'internet, OpenShift clusters with strict SCCs — without ever asking ' +
|
||||
'an operator to install drivers signed with test certificates or to ' +
|
||||
'flip "testsigning" on a target machine.'),
|
||||
]),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
// ── shell ────────────────────────────────────────────────────────
|
||||
const viewTitles = {
|
||||
dashboard: 'Dashboard',
|
||||
network: 'Network',
|
||||
gate: 'Forge Gate',
|
||||
storage: 'Storage',
|
||||
terminal: 'Terminal',
|
||||
about: 'About',
|
||||
};
|
||||
|
||||
let currentBody = null;
|
||||
|
||||
async function render(view) {
|
||||
view = view || 'dashboard';
|
||||
$$('.sidebar nav a').forEach(a => a.classList.toggle('active', a.dataset.view === view));
|
||||
$('[data-bind=view_title]').textContent = viewTitles[view] || view;
|
||||
const root = $('#view-root');
|
||||
// Clean up any per-view resources (e.g. terminal SSE) before swap.
|
||||
if (currentBody && typeof currentBody._cleanup === 'function') {
|
||||
try { currentBody._cleanup(); } catch (e) { /* ignore */ }
|
||||
}
|
||||
root.innerHTML = '';
|
||||
root.appendChild(el('div', {class:'msg'}, 'Loading…'));
|
||||
try {
|
||||
const body = await views[view]();
|
||||
root.innerHTML = '';
|
||||
root.appendChild(body);
|
||||
currentBody = body;
|
||||
} catch (e) {
|
||||
root.innerHTML = '';
|
||||
root.appendChild(el('div', {class:'msg err'}, 'Error: ' + e.message));
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshChips() {
|
||||
try {
|
||||
const s = await getJSON('/api/status');
|
||||
const r = await fetch('/readyz');
|
||||
$$('[data-bind=version]').forEach(n => n.textContent = s.version);
|
||||
$$('[data-bind=iso_count],[data-bind=iso_count2]').forEach(n => n.textContent = String(s.iso_count));
|
||||
$$('[data-bind=client_count],[data-bind=client_count2]').forEach(n => n.textContent = String(s.client_count));
|
||||
$$('[data-bind=gate_count],[data-bind=gate_count2]').forEach(n => n.textContent = String(s.gate_count));
|
||||
const chip = $('[data-bind=ready_chip]');
|
||||
if (chip) {
|
||||
if (r.ok) { chip.textContent = '● ready'; chip.className = 'chip ready'; }
|
||||
else { chip.textContent = '● not ready'; chip.className = 'chip notready'; }
|
||||
}
|
||||
} catch {
|
||||
const chip = $('[data-bind=ready_chip]');
|
||||
if (chip) { chip.textContent = '● unreachable'; chip.className = 'chip notready'; }
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const a = e.target.closest('.sidebar nav a[data-view]');
|
||||
if (a) { e.preventDefault(); render(a.dataset.view); }
|
||||
});
|
||||
|
||||
render('dashboard');
|
||||
refreshChips();
|
||||
setInterval(refreshChips, 3000);
|
||||
})();
|
||||
@@ -0,0 +1,54 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PXEForge</title>
|
||||
<link rel="stylesheet" href="/assets/app.css" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<img src="/assets/logo.svg" alt="" />
|
||||
<div>
|
||||
<strong>PXEForge</strong>
|
||||
<div class="sub">v<span data-bind="version">0.1.0</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<nav>
|
||||
<a data-view="dashboard" class="active">Dashboard</a>
|
||||
<a data-view="network">Network</a>
|
||||
<a data-view="gate">
|
||||
Forge Gate
|
||||
<span class="count" data-bind="gate_count">0</span>
|
||||
</a>
|
||||
<a data-view="storage">
|
||||
Storage
|
||||
<span class="count" data-bind="iso_count">0</span>
|
||||
</a>
|
||||
<a data-view="terminal">Terminal</a>
|
||||
<a data-view="about">About</a>
|
||||
</nav>
|
||||
<div class="footer">
|
||||
Advertised to clients<br/>
|
||||
<code>{{BASE_URL}}</code>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<header class="topbar">
|
||||
<h1 data-bind="view_title">Dashboard</h1>
|
||||
<div class="spacer"></div>
|
||||
<span class="chip" data-bind="ready_chip" title="Server readiness">checking…</span>
|
||||
<span class="chip"><strong data-bind="iso_count2">0</strong> images</span>
|
||||
<span class="chip"><strong data-bind="client_count2">0</strong> clients</span>
|
||||
<span class="chip"><strong data-bind="gate_count2">0</strong> at gate</span>
|
||||
</header>
|
||||
|
||||
<main class="main" id="view-root"></main>
|
||||
</div>
|
||||
|
||||
<script src="/assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Offline-only web UI. Everything the browser needs (HTML, CSS, JS, SVG
|
||||
//! logo) is embedded in the compiled binary via `include_str!` /
|
||||
//! `include_bytes!`. No CDN, no external fonts, no remote images —
|
||||
//! PXEForge renders identically on an air-gapped network.
|
||||
//!
|
||||
//! Layout follows the Netbox Labs pattern: dark left sidebar with primary
|
||||
//! nav, top bar with secondary tabs, card-dense content panels.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
/// Render the top-level page. `base_url` is interpolated into the footer
|
||||
/// so operators can see at a glance what URL clients are PXE-booting from.
|
||||
#[must_use]
|
||||
pub fn index_html(base_url: &str) -> String {
|
||||
INDEX_HTML.replace("{{BASE_URL}}", base_url)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn app_js() -> &'static str { APP_JS }
|
||||
|
||||
#[must_use]
|
||||
pub fn app_css() -> &'static str { APP_CSS }
|
||||
|
||||
#[must_use]
|
||||
pub fn logo_svg() -> &'static str { LOGO_SVG }
|
||||
|
||||
const INDEX_HTML: &str = include_str!("index.html");
|
||||
const APP_CSS: &str = include_str!("app.css");
|
||||
const APP_JS: &str = include_str!("app.js");
|
||||
const LOGO_SVG: &str = include_str!("logo.svg");
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg viewBox="0 0 96 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>PXEForge</title>
|
||||
<!-- Anvil body -->
|
||||
<path d="M6 22 H82 L70 36 H46 V44 H58 V50 H30 V44 H42 V36 H22 Z" fill="#f0823a" stroke="#3a1f08" stroke-width="1.2"/>
|
||||
<!-- Horn highlight -->
|
||||
<path d="M6 22 L20 22 L14 28 L6 28 Z" fill="#ffb066"/>
|
||||
<!-- Stand + base -->
|
||||
<rect x="34" y="50" width="20" height="4" fill="#3a1f08"/>
|
||||
<rect x="22" y="54" width="44" height="6" fill="#1c1107"/>
|
||||
<!-- Subtle spark -->
|
||||
<circle cx="86" cy="16" r="1.5" fill="#ffd79a"/>
|
||||
<circle cx="90" cy="22" r="1" fill="#ffd79a"/>
|
||||
<circle cx="82" cy="12" r="1" fill="#ffd79a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 650 B |
Reference in New Issue
Block a user