v0.2.0 — pre-beta: per-MAC bindings, /metrics, themes, animated forge

This is the bulk pre-beta cleanup pass. Bumps the workspace to 0.2.0.
Test count is 56 -> 66 (+10), clippy is fully clean across the
workspace (was several dozen warnings).

## New features

**Per-MAC host bindings** (Tinkerbell smee pattern). New
`HostBindings` registry maps a MAC -> preferred boot target, persisted
to <work_dir>/hosts.json. The DHCP reply now embeds `?mac=${mac}` in
the boot.ipxe URL; iPXE substitutes the literal MAC client-side, so
the HTTP layer can short-circuit straight to the bound target instead
of rendering the menu. Reserved menu shortcuts (`_local`, `_gate`,
`_tools_menu`) are valid targets too. New /api/hosts CRUD + a Hosts
tab in the sidebar.

**Prometheus `/metrics`** endpoint. Tiny lock-free implementation —
just AtomicU64s and a Display impl, no `prometheus` / `metrics-rs`
dep. Counters: DHCP replies (per arch label), DHCP declined, TFTP
transfers (per status), TFTP bytes, HTTP requests (per route).
Gauges: ISO count, client count, gate count, gate-imaging, NFS active
mounts, uptime, build info. Plain text exposition format,
text/plain;version=0.0.4 content-type, no auth (all metric values are
non-sensitive counts).

**Light + dark themes**. CSS tokens on `:root` and
`:root[data-theme=light]`, swap by toggle button (top-right) or `T`
hotkey. Persisted in localStorage; pre-paint inline script avoids
dark<->light flash. Light palette designed against the Netbox Labs
reference screenshot — near-white surfaces, soft grey dividers,
accent unchanged for brand consistency. Terminal pane stays dark in
both themes (it's a console, that's the right read).

**Animated SVG logo + forge widget**. New `logo.svg` is a refined
silver/grey anvil. New `anvil-forge.svg` adds rising sparks and a
pulsing underglow via SMIL — pure SVG, no GIF, no JS animation loop.
Used:
  - in the **forge progress** widget on Dashboard + Forge Gate, paired
    with a `linear-gradient(warn -> accent)` bar with a moving sheen;
    goes idle (greyscale, no sheen) at zero imaging load
  - in the page-load `<div class=loader>` that replaces the old
    "Loading..." text

## Code cleanup pass

`cargo clippy --workspace --all-targets` is now warning-free. Spot
fixes across the tree:
  - `format!()`-into-`String` -> `std::fmt::Write::write!`
  - manual reverse comparators -> `Reverse`
  - `map_or(false, ...)` -> `is_some_and`
  - redundant closures -> method references
  - `r#"..."#` raw strings without `"` -> `r"..."`
  - `std::io::Error::new(Other, ...)` -> `Error::other`
  - `as i32` on `c.id()` -> `cast_signed()`
  - merged identical match arms

## Windows workflow validation

New integration test synthesizes an ISO9660 with the SOURCES\\BOOT.WIM
sentinel, uploads it, asserts:
  1. introspection labels it `windows_pe` with has_boot_wim=true,
  2. the boot entry is `BootKind::Wimboot` with all five canonical
     files (bootmgr, bootmgr.efi, bcd, boot.sdi, boot.wim),
  3. the rendered iPXE script chains wimboot with `initrd --name`
     entries for each file, and
  4. NO trust-store strings appear in the rendered output: bcdedit,
     testsigning, certutil, httpdisk, and test-signed are all
     explicitly forbidden as a hard guarantee.

WinPE bootstrap (startnet.cmd) picks up the Bootimus v0.1.58 lessons:
explicit `net start Workstation` before `net use` to avoid the SMB
client lazy-init race, and surfaces errors instead of blind retries.

## Docs

architecture.md gains a "Phase 5" section explaining the host-bindings
+ metrics + theming + Windows-test work, plus a refreshed "deferred
to Phase 6" list (real-hardware integration, autounattend library,
distro profile manifest, WoL trigger, syslog receiver, IPv6).
README updates the status line, the "what it does" list, and adds
the new Hosts/Terminal tab names.
This commit is contained in:
Miles Ward
2026-04-30 02:28:10 -04:00
parent a9c4f408a9
commit 49d0b00a8a
31 changed files with 1651 additions and 209 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.2.0"
edition = "2021" edition = "2021"
rust-version = "1.80" rust-version = "1.80"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+18 -7
View File
@@ -5,10 +5,12 @@ Container-native PXE boot server. A Rust reimplementation of
for Docker/OCI and OpenShift. Upload `.iso` files via the web UI; network for Docker/OCI and OpenShift. Upload `.iso` files via the web UI; network
clients PXE-boot them. clients PXE-boot them.
> **Status:** Phase 3 MVP. Container image builds and runs, gate flow > **Status:** v0.2.0 / pre-beta. Phases 15 complete: full PXE stack,
> validated end-to-end (two clients join queue → operator assigns in UI → > Gated Deployment queue, NFS-share ISO sources, live tracing log + an
> both wake within 1 s with the correct boot script). Ready for real > operator terminal, per-MAC host bindings (Tinkerbell-style),
> hardware validation. > Prometheus `/metrics`, light/dark theme toggle, animated anvil
> imaging-progress widget. **66 tests passing**, clippy clean. Ready
> for real-hardware validation.
## Design non-negotiables ## Design non-negotiables
@@ -43,9 +45,18 @@ clients PXE-boot them.
selects *Gated Deployment* gets a numbered position and waits. The selects *Gated Deployment* gets a numbered position and waits. The
operator picks an ISO in the web UI and fires it to every waiting operator picks an ISO in the web UI and fires it to every waiting
client simultaneously. client simultaneously.
7. **Web UI** (Netbox-style): sidebar nav (Dashboard / Clients / Gated 7. **Web UI** (Netbox-style): sidebar nav (Dashboard / Network / Forge
Deployment / Images / Settings / About), top tabs, dark theme, teal Gate / Storage / Hosts / Terminal / About), light + dark themes
accents. All assets served from the binary — no external requests. (toggle top-right or press `T`), animated anvil "forge progress"
widget when devices are imaging. All assets served from the binary —
no external requests.
8. **Per-MAC host bindings.** Pin a MAC to a boot target and the client
skips the menu, chains straight through. Inspired by Tinkerbell's
`smee` MAC-prepended URL pattern.
9. **Prometheus metrics** at `/metrics` — DHCP replies by arch, TFTP
transfer counts and bytes, HTTP request counts by route, gate /
imaging gauges, uptime, build info. Plain text exposition format,
no external metrics framework dependency.
8. **Settings API** lets you change the default boot-menu timeout (default 8. **Settings API** lets you change the default boot-menu timeout (default
600s), the timeout action (stay / Local HDD / Gated Deployment), and 600s), the timeout action (stay / Local HDD / Gated Deployment), and
feature toggles like Windows ISO support. The iPXE scripts regenerate feature toggles like Windows ISO support. The iPXE scripts regenerate
+1 -2
View File
@@ -49,9 +49,8 @@ impl ClientArch {
// ARM32 UEFI: upstream boot.ipxe.org does not publish a prebuilt // ARM32 UEFI: upstream boot.ipxe.org does not publish a prebuilt
// snponly variant for this arch. We return None so the DHCP // snponly variant for this arch. We return None so the DHCP
// proxy declines rather than advertising a file we can't serve. // proxy declines rather than advertising a file we can't serve.
Self::Arm32Uefi => return None, Self::Arm32Uefi | Self::Unknown(_) => return None,
Self::Arm64Uefi => "snponly-arm64.efi", Self::Arm64Uefi => "snponly-arm64.efi",
Self::Unknown(_) => return None,
}) })
} }
+2 -1
View File
@@ -89,7 +89,8 @@ impl ClientRegistry {
pub fn list(&self) -> Vec<ClientSnapshot> { pub fn list(&self) -> Vec<ClientSnapshot> {
let guard = self.inner.read(); let guard = self.inner.read();
let mut v: Vec<_> = guard.values().cloned().collect(); let mut v: Vec<_> = guard.values().cloned().collect();
v.sort_by(|a, b| b.last_seen.cmp(&a.last_seen)); // Reverse-chronological by last-seen (most recent first).
v.sort_by_key(|c| std::cmp::Reverse(c.last_seen));
v v
} }
+4 -10
View File
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr}; use std::net::{IpAddr, Ipv4Addr};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct Config { pub struct Config {
pub server: ServerConfig, pub server: ServerConfig,
@@ -113,15 +113,9 @@ impl Default for Paths {
} }
} }
impl Default for Config { // `Config` derives `Default` because each component supplies its own
fn default() -> Self { // non-trivial defaults via `impl Default` blocks above; deriving keeps
Self { // this in sync if a new section is added.
server: ServerConfig::default(),
network: NetworkConfig::default(),
paths: Paths::default(),
}
}
}
impl Config { impl Config {
pub fn from_toml_file(path: &Path) -> crate::Result<Self> { pub fn from_toml_file(path: &Path) -> crate::Result<Self> {
+1 -1
View File
@@ -246,7 +246,7 @@ mod tests {
q3.touch(&id) q3.touch(&id)
}); });
tokio::time::sleep(std::time::Duration::from_millis(10)).await; tokio::time::sleep(std::time::Duration::from_millis(10)).await;
q2.assign(&[g.id.clone()], "x"); q2.assign(std::slice::from_ref(&g.id), "x");
let result = fut.await.unwrap(); let result = fut.await.unwrap();
assert!(result.is_some()); assert!(result.is_some());
assert_eq!(result.unwrap().assigned_target.as_deref(), Some("x")); assert_eq!(result.unwrap().assigned_target.as_deref(), Some("x"));
+235
View File
@@ -0,0 +1,235 @@
//! Per-MAC host bindings.
//!
//! Inspired by the Tinkerbell `smee` "MAC-prepended URL" pattern: an
//! operator can attach a preferred boot target (a `BootEntry::id`) to a
//! specific MAC address. When a client with that MAC arrives, the
//! top-level boot script chains straight to that target instead of
//! showing the interactive menu.
//!
//! Use cases:
//! - "This rack of Dell servers always images with Ubuntu Server 24.04"
//! - "Tom's laptop always boots from local disk"
//! - "Bench QA machines always boot Memtest until released"
//!
//! Persisted to `<work_dir>/hosts.json`. Like the SettingsStore, on-disk
//! corruption falls back to an empty registry rather than failing
//! startup — a bad hosts file should never block PXE for the network.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use time::OffsetDateTime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostBinding {
/// Lowercase, colon-separated MAC (e.g. `aa:bb:cc:dd:ee:ff`). The
/// HTTP layer normalizes incoming MACs before lookup so callers
/// don't have to worry about case.
pub mac: String,
/// Preferred boot entry id (matches a `BootEntry::id` in the iso
/// store) OR one of the reserved menu names: `_local`, `_gate`,
/// `_tools_menu`. Empty string falls back to the menu.
pub target: String,
/// Optional human-readable label shown in the UI (`"Tom's laptop"`,
/// `"rack-3 spine"`). Empty if unset.
#[serde(default)]
pub label: String,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
}
#[derive(Debug, Default)]
struct Inner {
by_mac: HashMap<String, HostBinding>,
}
/// Registry of per-MAC bindings. Cheap to clone; locks are held
/// briefly. Persistence is best-effort and mirrors `SettingsStore`'s
/// "in-memory authoritative, disk is a cache" policy.
#[derive(Debug, Clone)]
pub struct HostBindings {
path: Arc<PathBuf>,
inner: Arc<RwLock<Inner>>,
}
impl HostBindings {
/// Load from `work_dir/hosts.json`, or start empty if absent /
/// unreadable.
#[must_use]
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("hosts.json");
let inner = match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str::<Vec<HostBinding>>(&text) {
Ok(items) => {
let mut by_mac = HashMap::new();
for b in items {
by_mac.insert(normalize_mac(&b.mac), b);
}
Inner { by_mac }
}
Err(e) => {
tracing::warn!(
target: "pxeforge::hosts",
"hosts.json present but unreadable ({e}); starting empty"
);
Inner::default()
}
},
Err(_) => Inner::default(),
};
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(inner)),
}
}
/// Look up a binding by MAC. Match is case-insensitive and tolerates
/// `-` or `:` separators.
#[must_use]
pub fn lookup(&self, mac: &str) -> Option<HostBinding> {
self.inner.read().by_mac.get(&normalize_mac(mac)).cloned()
}
/// Insert or update. Returns the resulting binding (with timestamps).
pub fn upsert(&self, mac: &str, target: &str, label: &str) -> HostBinding {
let key = normalize_mac(mac);
let now = OffsetDateTime::now_utc();
let binding = {
let mut g = self.inner.write();
let entry = g.by_mac.entry(key.clone()).or_insert_with(|| HostBinding {
mac: key.clone(),
target: target.to_string(),
label: label.to_string(),
created_at: now,
updated_at: now,
});
entry.target = target.to_string();
entry.label = label.to_string();
entry.updated_at = now;
entry.clone()
};
self.persist();
binding
}
/// Remove a binding. Returns true if something was removed.
pub fn remove(&self, mac: &str) -> bool {
let key = normalize_mac(mac);
let removed = self.inner.write().by_mac.remove(&key).is_some();
if removed {
self.persist();
}
removed
}
#[must_use]
pub fn list(&self) -> Vec<HostBinding> {
let g = self.inner.read();
let mut v: Vec<_> = g.by_mac.values().cloned().collect();
v.sort_by(|a, b| a.mac.cmp(&b.mac));
v
}
#[must_use]
pub fn len(&self) -> usize {
self.inner.read().by_mac.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
fn persist(&self) {
let items: Vec<HostBinding> = self.inner.read().by_mac.values().cloned().collect();
let body = match serde_json::to_vec_pretty(&items) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "pxeforge::hosts", "serialize hosts.json: {e}");
return;
}
};
let tmp = self.path.with_extension("json.tmp");
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "pxeforge::hosts", "write hosts.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::warn!(target: "pxeforge::hosts", "rename hosts.json: {e}");
}
}
}
/// Lowercase a MAC and normalise `-` separators to `:`. We never strip
/// the separator entirely — `aabbccddeeff` formats are rejected at the
/// HTTP layer because they're ambiguous (could be a device id).
#[must_use]
pub fn normalize_mac(mac: &str) -> String {
mac.trim().to_ascii_lowercase().replace('-', ":")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn normalize_handles_case_and_dashes() {
assert_eq!(normalize_mac("AA:BB:CC:DD:EE:FF"), "aa:bb:cc:dd:ee:ff");
assert_eq!(normalize_mac("aa-bb-cc-dd-ee-ff"), "aa:bb:cc:dd:ee:ff");
assert_eq!(normalize_mac(" aA-Bb-CC:DD-ee:fF "), "aa:bb:cc:dd:ee:ff");
}
#[test]
fn upsert_then_lookup() {
let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path());
assert!(h.is_empty());
h.upsert("AA:BB:CC:00:00:01", "ubuntu-24-04-linux", "rack-3 spine");
let found = h.lookup("aa-bb-cc-00-00-01").expect("lookup");
assert_eq!(found.target, "ubuntu-24-04-linux");
assert_eq!(found.label, "rack-3 spine");
assert_eq!(found.mac, "aa:bb:cc:00:00:01");
}
#[test]
fn upsert_replaces_existing_target() {
let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path());
h.upsert("aa:bb:cc:00:00:01", "old-target", "label1");
h.upsert("aa:bb:cc:00:00:01", "new-target", "label2");
assert_eq!(h.len(), 1);
let b = h.lookup("aa:bb:cc:00:00:01").unwrap();
assert_eq!(b.target, "new-target");
assert_eq!(b.label, "label2");
}
#[test]
fn remove_works_and_reports_outcome() {
let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path());
h.upsert("aa:bb:cc:00:00:01", "x", "");
assert!(h.remove("AA:BB:CC:00:00:01"));
assert!(!h.remove("aa:bb:cc:00:00:01")); // already gone
assert!(h.is_empty());
}
#[test]
fn round_trip_persists_to_disk() {
let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path());
h.upsert("aa:bb:cc:00:00:01", "ubuntu-linux", "rack-3");
h.upsert("aa:bb:cc:00:00:02", "_local", "tom-laptop");
drop(h);
let h2 = HostBindings::load_or_default(dir.path());
assert_eq!(h2.len(), 2);
assert_eq!(h2.lookup("aa:bb:cc:00:00:02").unwrap().target, "_local");
}
}
+4
View File
@@ -7,7 +7,9 @@ pub mod client;
pub mod config; pub mod config;
pub mod error; pub mod error;
pub mod gate; pub mod gate;
pub mod host_bindings;
pub mod log_bus; pub mod log_bus;
pub mod metrics;
pub mod settings; pub mod settings;
pub use arch::{ClientArch, FirmwareClass}; pub use arch::{ClientArch, FirmwareClass};
@@ -15,5 +17,7 @@ pub use client::{ClientEvent, ClientRegistry, ClientSnapshot};
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig}; pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
pub use error::{Error, Result}; pub use error::{Error, Result};
pub use gate::{Gate, GateQueue}; pub use gate::{Gate, GateQueue};
pub use host_bindings::{normalize_mac, HostBinding, HostBindings};
pub use log_bus::{LogBus, LogBusLayer, LogLine}; pub use log_bus::{LogBus, LogBusLayer, LogLine};
pub use metrics::{HttpRoute, Metrics};
pub use settings::{Settings, SettingsStore, TimeoutAction}; pub use settings::{Settings, SettingsStore, TimeoutAction};
+283
View File
@@ -0,0 +1,283 @@
//! Tiny lock-free Prometheus-compatible metrics.
//!
//! We don't pull in `prometheus` or `metrics-rs` for this — they bring
//! their own runtime, registry, and complexity. PXEForge has a fixed,
//! tiny set of counters/gauges and the exposition format is plain text.
//! A handful of `AtomicU64`s and a `Display` impl gets us everything
//! Prometheus / Grafana / VictoriaMetrics needs to scrape:
//!
//! pxeforge_dhcp_replies_total counter (per arch label)
//! pxeforge_tftp_transfers_total counter (per status label)
//! pxeforge_tftp_bytes_total counter
//! pxeforge_http_requests_total counter (per route label)
//! pxeforge_iso_count gauge
//! pxeforge_client_count gauge
//! pxeforge_gate_count gauge
//! pxeforge_gate_imaging gauge
//! pxeforge_uptime_seconds gauge
//! pxeforge_build_info{version} gauge (always 1)
//!
//! Cheap to clone — internal state is a couple of arcs. Counters use
//! `Relaxed` ordering: we don't synchronise across counters, just need
//! per-counter monotonicity.
use std::fmt::Write as _;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
#[derive(Debug, Default)]
#[allow(clippy::struct_field_names)]
struct Inner {
// DHCP proxy
dhcp_replies_legacy: AtomicU64,
dhcp_replies_uefi: AtomicU64,
dhcp_replies_arm64: AtomicU64,
dhcp_replies_unknown: AtomicU64,
dhcp_declined: AtomicU64,
// TFTP
tftp_transfers_ok: AtomicU64,
tftp_transfers_err: AtomicU64,
tftp_bytes: AtomicU64,
// HTTP
http_boot_script: AtomicU64,
http_iso_range: AtomicU64,
http_iso_inner: AtomicU64,
http_ipxe_binary: AtomicU64,
http_api: AtomicU64,
// Gauges (set explicitly; not cumulative)
iso_count: AtomicU64,
client_count: AtomicU64,
gate_count: AtomicU64,
gate_imaging: AtomicU64,
nfs_mounts_active: AtomicU64,
}
#[derive(Debug, Clone, Default)]
pub struct Metrics {
inner: Arc<Inner>,
}
impl Metrics {
#[must_use]
pub fn new() -> Self {
Self::default()
}
// ── DHCP ───────────────────────────────────────────────────────────
pub fn record_dhcp_reply(&self, arch: &str) {
let counter = match arch {
"bios" => &self.inner.dhcp_replies_legacy,
"uefi-x64" | "uefi-ia32" => &self.inner.dhcp_replies_uefi,
"uefi-arm64" => &self.inner.dhcp_replies_arm64,
_ => &self.inner.dhcp_replies_unknown,
};
counter.fetch_add(1, Ordering::Relaxed);
}
pub fn record_dhcp_decline(&self) {
self.inner.dhcp_declined.fetch_add(1, Ordering::Relaxed);
}
// ── TFTP ───────────────────────────────────────────────────────────
pub fn record_tftp_ok(&self, bytes: u64) {
self.inner.tftp_transfers_ok.fetch_add(1, Ordering::Relaxed);
self.inner.tftp_bytes.fetch_add(bytes, Ordering::Relaxed);
}
pub fn record_tftp_err(&self) {
self.inner.tftp_transfers_err.fetch_add(1, Ordering::Relaxed);
}
// ── HTTP ───────────────────────────────────────────────────────────
pub fn record_http(&self, route: HttpRoute) {
let counter = match route {
HttpRoute::BootScript => &self.inner.http_boot_script,
HttpRoute::IsoRange => &self.inner.http_iso_range,
HttpRoute::IsoInner => &self.inner.http_iso_inner,
HttpRoute::IpxeBinary => &self.inner.http_ipxe_binary,
HttpRoute::Api => &self.inner.http_api,
};
counter.fetch_add(1, Ordering::Relaxed);
}
// ── Gauges ─────────────────────────────────────────────────────────
pub fn set_iso_count(&self, n: u64) {
self.inner.iso_count.store(n, Ordering::Relaxed);
}
pub fn set_client_count(&self, n: u64) {
self.inner.client_count.store(n, Ordering::Relaxed);
}
pub fn set_gate_counts(&self, total: u64, imaging: u64) {
self.inner.gate_count.store(total, Ordering::Relaxed);
self.inner.gate_imaging.store(imaging, Ordering::Relaxed);
}
pub fn set_nfs_active(&self, n: u64) {
self.inner.nfs_mounts_active.store(n, Ordering::Relaxed);
}
/// Render in the Prometheus text exposition format.
/// Uptime is supplied by the caller because `Metrics` doesn't own
/// the start instant; the HTTP layer does.
#[must_use]
pub fn render(&self, version: &str, uptime_secs: u64) -> String {
let mut out = String::with_capacity(2048);
let i = &self.inner;
// Helper closures.
let write_counter = |o: &mut String, name: &str, help: &str, val: u64, lbl: &str| {
let _ = writeln!(o, "# HELP {name} {help}");
let _ = writeln!(o, "# TYPE {name} counter");
if lbl.is_empty() {
let _ = writeln!(o, "{name} {val}");
} else {
let _ = writeln!(o, "{name}{{{lbl}}} {val}");
}
};
let write_gauge = |o: &mut String, name: &str, help: &str, val: u64, lbl: &str| {
let _ = writeln!(o, "# HELP {name} {help}");
let _ = writeln!(o, "# TYPE {name} gauge");
if lbl.is_empty() {
let _ = writeln!(o, "{name} {val}");
} else {
let _ = writeln!(o, "{name}{{{lbl}}} {val}");
}
};
// Counters with one HELP/TYPE per metric name and per-label rows.
let _ = writeln!(out, "# HELP pxeforge_dhcp_replies_total Number of proxyDHCP replies sent, by client architecture.");
let _ = writeln!(out, "# TYPE pxeforge_dhcp_replies_total counter");
let _ = writeln!(
out,
"pxeforge_dhcp_replies_total{{arch=\"bios\"}} {}",
i.dhcp_replies_legacy.load(Ordering::Relaxed)
);
let _ = writeln!(
out,
"pxeforge_dhcp_replies_total{{arch=\"uefi\"}} {}",
i.dhcp_replies_uefi.load(Ordering::Relaxed)
);
let _ = writeln!(
out,
"pxeforge_dhcp_replies_total{{arch=\"arm64\"}} {}",
i.dhcp_replies_arm64.load(Ordering::Relaxed)
);
let _ = writeln!(
out,
"pxeforge_dhcp_replies_total{{arch=\"unknown\"}} {}",
i.dhcp_replies_unknown.load(Ordering::Relaxed)
);
write_counter(
&mut out,
"pxeforge_dhcp_declined_total",
"DHCP requests we saw but did not reply to (mac filter, arch unsupported, etc).",
i.dhcp_declined.load(Ordering::Relaxed),
"",
);
let _ = writeln!(out, "# HELP pxeforge_tftp_transfers_total TFTP transfers, by status.");
let _ = writeln!(out, "# TYPE pxeforge_tftp_transfers_total counter");
let _ = writeln!(
out,
"pxeforge_tftp_transfers_total{{status=\"ok\"}} {}",
i.tftp_transfers_ok.load(Ordering::Relaxed)
);
let _ = writeln!(
out,
"pxeforge_tftp_transfers_total{{status=\"err\"}} {}",
i.tftp_transfers_err.load(Ordering::Relaxed)
);
write_counter(
&mut out,
"pxeforge_tftp_bytes_total",
"Total bytes successfully delivered over TFTP.",
i.tftp_bytes.load(Ordering::Relaxed),
"",
);
let _ = writeln!(out, "# HELP pxeforge_http_requests_total HTTP requests served, by route family.");
let _ = writeln!(out, "# TYPE pxeforge_http_requests_total counter");
for (label, counter) in [
("boot_script", &i.http_boot_script),
("iso_range", &i.http_iso_range),
("iso_inner", &i.http_iso_inner),
("ipxe_binary", &i.http_ipxe_binary),
("api", &i.http_api),
] {
let _ = writeln!(
out,
"pxeforge_http_requests_total{{route=\"{label}\"}} {}",
counter.load(Ordering::Relaxed)
);
}
// Gauges.
write_gauge(&mut out, "pxeforge_iso_count", "ISOs currently registered (local + NFS).", i.iso_count.load(Ordering::Relaxed), "");
write_gauge(&mut out, "pxeforge_client_count", "PXE clients seen this process lifetime.", i.client_count.load(Ordering::Relaxed), "");
write_gauge(&mut out, "pxeforge_gate_count", "Clients currently waiting at the deployment gate.", i.gate_count.load(Ordering::Relaxed), "");
write_gauge(&mut out, "pxeforge_gate_imaging", "Clients currently imaging (gate + assigned target).", i.gate_imaging.load(Ordering::Relaxed), "");
write_gauge(&mut out, "pxeforge_nfs_mounts_active", "NFS shares currently mounted.", i.nfs_mounts_active.load(Ordering::Relaxed), "");
write_gauge(&mut out, "pxeforge_uptime_seconds", "Seconds since this PXEForge instance started.", uptime_secs, "");
let _ = writeln!(out, "# HELP pxeforge_build_info Build metadata. Always 1; the version is in the label.");
let _ = writeln!(out, "# TYPE pxeforge_build_info gauge");
let _ = writeln!(out, "pxeforge_build_info{{version=\"{version}\"}} 1");
out
}
}
/// Stable label values for the HTTP route counter. Adding a new route
/// here without updating `record_http` will break compilation, which is
/// exactly the safety we want — Prometheus alerts on cardinality drift,
/// so accidental new label values matter.
#[derive(Debug, Clone, Copy)]
pub enum HttpRoute {
BootScript,
IsoRange,
IsoInner,
IpxeBinary,
Api,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_emits_each_metric_family_once() {
let m = Metrics::new();
m.record_dhcp_reply("uefi-x64");
m.record_dhcp_reply("bios");
m.record_tftp_ok(1024);
m.record_http(HttpRoute::Api);
m.set_iso_count(3);
let out = m.render("0.2.0", 42);
assert_eq!(out.matches("# TYPE pxeforge_dhcp_replies_total counter").count(), 1);
assert_eq!(out.matches("# TYPE pxeforge_iso_count gauge").count(), 1);
assert!(out.contains("pxeforge_dhcp_replies_total{arch=\"uefi\"} 1"));
assert!(out.contains("pxeforge_dhcp_replies_total{arch=\"bios\"} 1"));
assert!(out.contains("pxeforge_tftp_transfers_total{status=\"ok\"} 1"));
assert!(out.contains("pxeforge_tftp_bytes_total 1024"));
assert!(out.contains("pxeforge_http_requests_total{route=\"api\"} 1"));
assert!(out.contains("pxeforge_iso_count 3"));
assert!(out.contains("pxeforge_uptime_seconds 42"));
assert!(out.contains("pxeforge_build_info{version=\"0.2.0\"} 1"));
}
#[test]
fn cloned_metrics_share_state() {
let a = Metrics::new();
let b = a.clone();
a.record_dhcp_reply("bios");
b.record_dhcp_reply("bios");
let out = a.render("test", 0);
assert!(out.contains("pxeforge_dhcp_replies_total{arch=\"bios\"} 2"));
}
}
+8 -1
View File
@@ -41,7 +41,14 @@ pub struct ReplyContext<'a> {
pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective { pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
match ctx.class { match ctx.class {
FirmwareClass::IpxeUserClass => BootDirective::HttpScript { FirmwareClass::IpxeUserClass => BootDirective::HttpScript {
url: format!("{}/boot.ipxe", ctx.public_base_url.trim_end_matches('/')), // Pass the client's MAC in the query string so the HTTP
// layer can short-circuit to a per-MAC binding when one
// exists. iPXE substitutes `${mac}` literally before issuing
// the GET, so this stays static across firmwares.
url: format!(
"{}/boot.ipxe?mac=${{mac}}",
ctx.public_base_url.trim_end_matches('/')
),
}, },
FirmwareClass::HttpClient => { FirmwareClass::HttpClient => {
// UEFI HTTP boot: client wants an http:// URL in option 67 // UEFI HTTP boot: client wants an http:// URL in option 67
+13 -1
View File
@@ -19,6 +19,7 @@ pub struct DhcpProxyServer {
our_ip: Ipv4Addr, our_ip: Ipv4Addr,
public_base_url: String, public_base_url: String,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: pxeforge_core::Metrics,
} }
impl DhcpProxyServer { impl DhcpProxyServer {
@@ -29,8 +30,17 @@ impl DhcpProxyServer {
our_ip: Ipv4Addr, our_ip: Ipv4Addr,
public_base_url: String, public_base_url: String,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: pxeforge_core::Metrics,
) -> Self { ) -> Self {
Self { bind, dhcp_port, pxe_port, our_ip, public_base_url, clients } Self {
bind,
dhcp_port,
pxe_port,
our_ip,
public_base_url,
clients,
metrics,
}
} }
pub async fn run(self) -> anyhow::Result<()> { pub async fn run(self) -> anyhow::Result<()> {
@@ -116,12 +126,14 @@ impl DhcpProxyServer {
}; };
let directive = decide(&ctx); let directive = decide(&ctx);
if matches!(directive, BootDirective::Ignore) { if matches!(directive, BootDirective::Ignore) {
self.metrics.record_dhcp_decline();
tracing::debug!( tracing::debug!(
target: "pxeforge::dhcp", target: "pxeforge::dhcp",
mac=%mac, arch=?arch, "ignoring — no bootfile for arch" mac=%mac, arch=?arch, "ignoring — no bootfile for arch"
); );
return Ok(()); return Ok(());
} }
self.metrics.record_dhcp_reply(arch.as_str());
let Some(reply) = build_reply(&ctx, &directive) else { return Ok(()); }; let Some(reply) = build_reply(&ctx, &directive) else { return Ok(()); };
let mut out = Vec::with_capacity(512); let mut out = Vec::with_capacity(512);
+159 -4
View File
@@ -45,6 +45,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/assets/app.js", get(ui_js)) .route("/assets/app.js", get(ui_js))
.route("/assets/app.css", get(ui_css)) .route("/assets/app.css", get(ui_css))
.route("/assets/logo.svg", get(ui_logo)) .route("/assets/logo.svg", get(ui_logo))
.route("/assets/anvil-forge.svg", get(ui_anvil_forge))
// iPXE script endpoints. // iPXE script endpoints.
.route("/boot.ipxe", get(boot_top_menu)) .route("/boot.ipxe", get(boot_top_menu))
.route("/boot/:filename", get(boot_sub)) .route("/boot/:filename", get(boot_sub))
@@ -80,6 +81,14 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/log/clear", post(log_stream::clear)) .route("/api/log/clear", post(log_stream::clear))
// Phase 4: operator terminal commands (whitelisted). // Phase 4: operator terminal commands (whitelisted).
.route("/api/terminal", post(terminal::run_command)) .route("/api/terminal", post(terminal::run_command))
// Phase 5: per-MAC host bindings (Tinkerbell-style). Operator
// pins a MAC to a boot entry; /boot.ipxe?mac=... chains directly.
.route("/api/hosts", get(api_hosts_list).post(api_hosts_upsert))
.route("/api/hosts/:mac", delete(api_hosts_remove))
// Phase 5: Prometheus scrape endpoint. Plain text exposition
// format. No auth — the metrics surface is intentionally
// boring (counts, no payloads).
.route("/metrics", get(api_metrics))
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
// 16 GiB upload cap — ISOs are big; chunks stream so this isn't memory use. // 16 GiB upload cap — ISOs are big; chunks stream so this isn't memory use.
.layer(DefaultBodyLimit::max(16 * 1024 * 1024 * 1024)) .layer(DefaultBodyLimit::max(16 * 1024 * 1024 * 1024))
@@ -115,6 +124,13 @@ async fn ui_logo() -> Response {
).into_response() ).into_response()
} }
async fn ui_anvil_forge() -> Response {
(
[(header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml"))],
pxeforge_webui::anvil_forge_svg(),
).into_response()
}
// ─── iPXE scripts ────────────────────────────────────────────────────────── // ─── iPXE scripts ──────────────────────────────────────────────────────────
fn text_plain(body: String) -> Response { fn text_plain(body: String) -> Response {
@@ -122,10 +138,51 @@ fn text_plain(body: String) -> Response {
.into_response() .into_response()
} }
async fn boot_top_menu(State(state): State<AppState>) -> Response { /// Top-level boot script. Honors per-MAC host bindings: if the
/// requesting client carries a `?mac=...` query param (iPXE's `${mac}`
/// substitution) and that MAC has a binding, we short-circuit straight
/// to the bound target instead of rendering the menu.
async fn boot_top_menu(
State(state): State<AppState>,
Query(p): Query<BootMenuParams>,
) -> Response {
state.metrics.record_http(pxeforge_core::HttpRoute::BootScript);
let isos = state.iso_store.list(); let isos = state.iso_store.list();
let settings = state.settings.snapshot(); let settings = state.settings.snapshot();
text_plain(render_menu(&isos, &settings, &state.public_base_url)) let base = &state.public_base_url;
// Per-MAC override: if the client identified itself and we have a
// binding, chain directly. The chain target falls back to the menu
// on failure so a stale / misconfigured binding can't lock a client
// out — it just shows the menu.
if let Some(mac) = p.mac.as_deref() {
if let Some(binding) = state.hosts.lookup(mac) {
tracing::info!(
target: "pxeforge::http",
mac = %binding.mac, target = %binding.target,
"host binding applied"
);
let target = binding.target;
// Reserved menu shortcuts are emitted as `_xxx`; per-entry
// boot scripts are at `/boot/<id>.ipxe`. Both share the same
// `/boot/<name>` route, so the URL is identical.
return text_plain(format!(
"#!ipxe\n\
echo PXEForge: per-MAC binding -> {target}\n\
chain {base}/boot/{target}.ipxe || chain {base}/boot.ipxe\n"
));
}
}
text_plain(render_menu(&isos, &settings, base))
}
#[derive(Debug, Deserialize)]
struct BootMenuParams {
/// Client MAC, supplied by iPXE via `${mac}` variable in
/// `chain ${prefix}/boot.ipxe?mac=${mac}`. Optional — if absent we
/// fall back to the menu unconditionally.
mac: Option<String>,
} }
async fn boot_sub( async fn boot_sub(
@@ -204,7 +261,7 @@ async fn iso_file(
return (StatusCode::NOT_FOUND, "no such iso").into_response(); return (StatusCode::NOT_FOUND, "no such iso").into_response();
}; };
let p = iso_path.clone(); let p = iso_path.clone();
let in_path = format!("/{}", path); let in_path = format!("/{path}");
let loc = tokio::task::spawn_blocking(move || iso_fs::lookup(&p, &in_path)) let loc = tokio::task::spawn_blocking(move || iso_fs::lookup(&p, &in_path))
.await.ok().flatten(); .await.ok().flatten();
let Some(loc) = loc else { let Some(loc) = loc else {
@@ -362,18 +419,27 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
let nfs = state.nfs.list(); let nfs = state.nfs.list();
let nfs_active = nfs.iter().filter(|m| m.mounted).count(); let nfs_active = nfs.iter().filter(|m| m.mounted).count();
let isos = state.iso_store.list(); let isos = state.iso_store.list();
let clients = state.clients.list();
let gates = state.gates.list(); let gates = state.gates.list();
// Phase 4: dashboard tracks "imaging" as gates with an assignment // Phase 4: dashboard tracks "imaging" as gates with an assignment
// already issued — they're the ones actively chaining a boot script. // already issued — they're the ones actively chaining a boot script.
let imaging = gates.iter().filter(|g| g.assigned_target.is_some()).count(); let imaging = gates.iter().filter(|g| g.assigned_target.is_some()).count();
let waiting = gates.len() - imaging; let waiting = gates.len() - imaging;
// Side-effect: push gauge values out to the Prometheus surface.
// Doing it here (in the most-frequently-polled endpoint) keeps the
// gauges fresh without a dedicated scrape-time hook.
state.metrics.set_iso_count(isos.len() as u64);
state.metrics.set_client_count(clients.len() as u64);
state.metrics.set_gate_counts(gates.len() as u64, imaging as u64);
state.metrics.set_nfs_active(nfs_active as u64);
state.metrics.record_http(pxeforge_core::HttpRoute::Api);
let now = time::OffsetDateTime::now_utc(); let now = time::OffsetDateTime::now_utc();
let uptime_secs = (now - state.started_at).whole_seconds().max(0); let uptime_secs = (now - state.started_at).whole_seconds().max(0);
Json(json!({ Json(json!({
"version": env!("CARGO_PKG_VERSION"), "version": env!("CARGO_PKG_VERSION"),
"public_base_url": state.public_base_url, "public_base_url": state.public_base_url,
"iso_count": isos.len(), "iso_count": isos.len(),
"client_count": state.clients.list().len(), "client_count": clients.len(),
"gate_count": gates.len(), "gate_count": gates.len(),
"imaging_count": imaging, "imaging_count": imaging,
"waiting_count": waiting, "waiting_count": waiting,
@@ -382,6 +448,7 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
"smb": smb, "smb": smb,
"nfs_count": nfs.len(), "nfs_count": nfs.len(),
"nfs_active": nfs_active, "nfs_active": nfs_active,
"host_bindings": state.hosts.len(),
"uptime_secs": uptime_secs, "uptime_secs": uptime_secs,
"started_at": state.started_at, "started_at": state.started_at,
"nic_name": state.nic_name, "nic_name": state.nic_name,
@@ -650,6 +717,94 @@ async fn api_network_put(
StatusCode::NO_CONTENT StatusCode::NO_CONTENT
} }
// ─── Per-MAC host bindings ────────────────────────────────────────────────
async fn api_hosts_list(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!({ "hosts": state.hosts.list() }))
}
#[derive(Debug, Deserialize)]
struct HostsUpsertBody {
mac: String,
target: String,
#[serde(default)]
label: String,
}
async fn api_hosts_upsert(
State(state): State<AppState>,
Json(body): Json<HostsUpsertBody>,
) -> Response {
let mac = body.mac.trim();
if mac.is_empty() {
return (StatusCode::BAD_REQUEST, "mac is required").into_response();
}
// Sanity-check the target if the operator supplied a real boot
// entry id (anything starting with `_` is a reserved menu shortcut
// and exists by definition).
let target = body.target.trim();
if !target.starts_with('_')
&& !state
.iso_store
.list()
.into_iter()
.any(|i| i.boot_entries.iter().any(|e| e.id == target))
{
return (
StatusCode::BAD_REQUEST,
format!("unknown boot entry: {target}"),
)
.into_response();
}
let binding = state.hosts.upsert(mac, target, body.label.trim());
(StatusCode::CREATED, Json(binding)).into_response()
}
async fn api_hosts_remove(
State(state): State<AppState>,
AxumPath(mac): AxumPath<String>,
) -> StatusCode {
if state.hosts.remove(&mac) {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
// ─── Prometheus metrics ───────────────────────────────────────────────────
async fn api_metrics(State(state): State<AppState>) -> Response {
// Refresh gauges from live state before rendering — keeps the
// scrape "honest" without making /api/status the only path that
// updates them.
state
.metrics
.set_iso_count(state.iso_store.list().len() as u64);
state
.metrics
.set_client_count(state.clients.list().len() as u64);
let gates = state.gates.list();
let imaging = gates.iter().filter(|g| g.assigned_target.is_some()).count();
state
.metrics
.set_gate_counts(gates.len() as u64, imaging as u64);
state
.metrics
.set_nfs_active(state.nfs.list().iter().filter(|m| m.mounted).count() as u64);
let now = time::OffsetDateTime::now_utc();
let uptime = (now - state.started_at).whole_seconds().max(0) as u64;
let body = state.metrics.render(env!("CARGO_PKG_VERSION"), uptime);
(
[(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"),
)],
body,
)
.into_response()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+4 -4
View File
@@ -35,11 +35,11 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
let timeout_ms = settings.boot_menu_timeout_secs.saturating_mul(1000); let timeout_ms = settings.boot_menu_timeout_secs.saturating_mul(1000);
let default_item = match settings.timeout_action { let default_item = match settings.timeout_action {
TimeoutAction::LocalHdd => "local",
TimeoutAction::GatedDeployment => "gate", TimeoutAction::GatedDeployment => "gate",
// Stay -> iPXE's `--timeout 0` is "no timeout". Pick any default // Stay -> iPXE's `--timeout 0` is "no timeout". Pick any default
// label; the client waits for keypress. // label; the client waits for keypress. We use the same label as
TimeoutAction::Stay => "local", // LocalHdd to keep the menu's pre-highlight stable.
TimeoutAction::LocalHdd | TimeoutAction::Stay => "local",
}; };
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
@@ -135,7 +135,7 @@ pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) ->
/// operator expectations from the original tool). /// operator expectations from the original tool).
fn fmt_size_mib(bytes: u64) -> String { fn fmt_size_mib(bytes: u64) -> String {
let mib = bytes / (1024 * 1024); let mib = bytes / (1024 * 1024);
format!("{} MB", mib) format!("{mib} MB")
} }
/// Assign `--key N <id>` hotkeys 1..9, then nothing for positions >=9. /// Assign `--key N <id>` hotkeys 1..9, then nothing for positions >=9.
+2 -2
View File
@@ -6,8 +6,8 @@
//! - `/ipxe/<file>` bundled iPXE binaries (for UEFI HTTP boot) //! - `/ipxe/<file>` bundled iPXE binaries (for UEFI HTTP boot)
//! - `/iso/<id>.iso` raw ISO file (with Range support) //! - `/iso/<id>.iso` raw ISO file (with Range support)
//! - `/iso/<id>/<path>` files inside the ISO (for wimboot WIM fetches //! - `/iso/<id>/<path>` files inside the ISO (for wimboot WIM fetches
//! and Linux kernel/initrd, without having to //! and Linux kernel/initrd, without having to
//! re-extract on every request) //! re-extract on every request)
//! //!
//! The `<id>/<path>` handler uses a read-only ISO9660 shim (see `iso_fs`) //! 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. //! that lseeks into the ISO on disk — so we never keep extracted copies.
+8 -1
View File
@@ -1,4 +1,4 @@
use pxeforge_core::{ClientRegistry, GateQueue, LogBus, SettingsStore}; use pxeforge_core::{ClientRegistry, GateQueue, HostBindings, LogBus, Metrics, SettingsStore};
use pxeforge_iso_store::{IsoStore, NfsManager, SmbManager}; use pxeforge_iso_store::{IsoStore, NfsManager, SmbManager};
use std::sync::Arc; use std::sync::Arc;
use time::OffsetDateTime; use time::OffsetDateTime;
@@ -9,6 +9,13 @@ pub struct AppState {
pub clients: Arc<ClientRegistry>, pub clients: Arc<ClientRegistry>,
pub settings: Arc<SettingsStore>, pub settings: Arc<SettingsStore>,
pub gates: Arc<GateQueue>, pub gates: Arc<GateQueue>,
/// Per-MAC iPXE script overrides. When a client matching one of
/// these MACs requests `/boot.ipxe`, we chain straight to the
/// configured target instead of rendering the menu.
pub hosts: HostBindings,
/// Lock-free metrics counters surfaced at `/metrics` in Prometheus
/// text format. Cheap to clone (handles to atomics).
pub metrics: Metrics,
/// Optional SMB manager. Present when the binary was given a writable /// Optional SMB manager. Present when the binary was given a writable
/// `smb_dir` at startup; `None` in pure-Linux-only deployments where /// `smb_dir` at startup; `None` in pure-Linux-only deployments where
/// Windows support is not wired in. Settings toggle drives start/stop. /// Windows support is not wired in. Settings toggle drives start/stop.
+4 -4
View File
@@ -161,8 +161,8 @@ fn clients_text(s: &AppState) -> String {
let mut out = String::new(); let mut out = String::new();
let _ = writeln!( let _ = writeln!(
out, out,
"{:<19} {:<16} {:<8} {}", "{:<19} {:<16} {:<8} LAST SEEN",
"MAC", "IP", "EVENTS", "LAST SEEN" "MAC", "IP", "EVENTS"
); );
for c in clients { for c in clients {
let ip = c.last_ip.map_or_else(|| "-".into(), |i| i.to_string()); let ip = c.last_ip.map_or_else(|| "-".into(), |i| i.to_string());
@@ -259,8 +259,8 @@ async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
let mut out = String::new(); let mut out = String::new();
let _ = writeln!( let _ = writeln!(
out, out,
"{:<24} {:<6} {:<7} {:<6} {}", "{:<24} {:<6} {:<7} {:<6} TARGET",
"ID", "VER", "STATUS", "ISOS", "TARGET" "ID", "VER", "STATUS", "ISOS"
); );
for m in mounts { for m in mounts {
let status = if m.mounted { "ok" } else { "down" }; let status = if m.mounted { "ok" } else { "down" };
+207 -3
View File
@@ -12,7 +12,7 @@
use axum::body::Body; use axum::body::Body;
use axum::http::{header, Request, StatusCode}; use axum::http::{header, Request, StatusCode};
use pxeforge_core::{ClientRegistry, GateQueue, LogBus, SettingsStore}; use pxeforge_core::{ClientRegistry, GateQueue, HostBindings, LogBus, Metrics, SettingsStore};
use pxeforge_http_api::{build_router, AppState}; use pxeforge_http_api::{build_router, AppState};
use pxeforge_iso_store::{IsoStore, NfsManager}; use pxeforge_iso_store::{IsoStore, NfsManager};
use tempfile::tempdir; use tempfile::tempdir;
@@ -92,11 +92,15 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
let nfs = NfsManager::new(dir.path(), iso_store.clone()); let nfs = NfsManager::new(dir.path(), iso_store.clone());
iso_store.set_nfs_root(nfs.mount_root()); iso_store.set_nfs_root(nfs.mount_root());
let log_bus = LogBus::new(64); let log_bus = LogBus::new(64);
let hosts = HostBindings::load_or_default(dir.path());
let metrics = Metrics::new();
let state = AppState { let state = AppState {
iso_store, iso_store,
clients, clients,
gates, gates,
settings, settings,
hosts,
metrics,
smb: None, smb: None,
nfs, nfs,
log_bus, log_bus,
@@ -236,8 +240,8 @@ async fn gated_deployment_full_flow() {
assert!(assign_json.contains(r#""assigned":1"#), "assign response: {assign_json}"); assert!(assign_json.contains(r#""assigned":1"#), "assign response: {assign_json}");
// Now assign to gate 1 too so the background poll wakes. // Now assign to gate 1 too so the background poll wakes.
let body = format!(r#"{{"target":"fake-alpine-linux","gate_ids":[]}}"#); let body = r#"{"target":"fake-alpine-linux","gate_ids":[]}"#;
post_json(&app, "/api/gate/assign", &body).await; post_json(&app, "/api/gate/assign", body).await;
let (poll_status, poll_body) = poll_future.await.unwrap(); let (poll_status, poll_body) = poll_future.await.unwrap();
assert_eq!(poll_status, StatusCode::OK); assert_eq!(poll_status, StatusCode::OK);
@@ -325,6 +329,7 @@ async fn ui_assets_served_offline() {
("/assets/app.js", "application/javascript"), ("/assets/app.js", "application/javascript"),
("/assets/app.css", "text/css"), ("/assets/app.css", "text/css"),
("/assets/logo.svg", "image/svg+xml"), ("/assets/logo.svg", "image/svg+xml"),
("/assets/anvil-forge.svg", "image/svg+xml"),
] { ] {
let res = app let res = app
.clone() .clone()
@@ -438,6 +443,205 @@ async fn log_recent_returns_buffered_lines() {
} }
} }
#[tokio::test]
async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
// Synthesize an ISO with a Windows volume label + the sources/boot.wim
// sentinel so introspection labels it WindowsPe with has_boot_wim.
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"WIN11_X64".to_vec();
let mut padded = label.clone();
padded.resize(32, b' ');
buf[off + 40..off + 40 + 32].copy_from_slice(&padded);
// Sprinkle the sources/boot.wim sentinel where the introspection
// scanner will find it (anywhere in the first 64 MB).
let sentinel = b"SOURCES\\BOOT.WIM";
buf.extend_from_slice(sentinel);
let term = 17 * 2048;
buf[term] = 0xFF;
buf[term + 1..term + 6].copy_from_slice(b"CD001");
buf[term + 6] = 0x01;
let (state, _dir) = build_state().await;
let app = build_router(state);
// Need windows_enabled for the Windows path to render in the menu.
let res = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/settings")
.header("content-type", "application/json")
.body(Body::from(
r#"{"boot_menu_timeout_secs":600,"timeout_action":"gated_deployment",
"windows_enabled":false,"smb_host_override":"","extra_kernel_args":"",
"default_local_hdd":true,"gate_wait_max_secs":0,"dns_server":""}"#
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
// wimboot binary is bundled in this repo so windows_enabled=true should
// not be rejected; we leave it false to keep the upload path agnostic.
assert_eq!(res.status(), StatusCode::NO_CONTENT);
let (ct, body) = multipart_iso_body("Win11_x64.iso", &buf);
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);
let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
let meta: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(meta["introspection"]["family"], "windows_pe");
assert!(
meta["introspection"]["has_boot_wim"].as_bool().unwrap(),
"introspection should detect sources/boot.wim sentinel"
);
// The boot entry should be a wimboot kind with the canonical 5-file
// chain documented in the LinusTechTips iPXE-Windows guide.
let entry = &meta["boot_entries"][0];
assert_eq!(entry["kind"]["kind"], "wimboot");
let files = entry["kind"]["files"].as_array().unwrap();
let names: Vec<&str> = files.iter().map(|f| f[0].as_str().unwrap()).collect();
assert!(names.contains(&"bootmgr"));
assert!(names.contains(&"bootmgr.efi"));
assert!(names.contains(&"bcd"));
assert!(names.contains(&"boot.sdi"));
assert!(names.contains(&"boot.wim"));
// Render the entry script and verify:
// 1. It uses wimboot
// 2. All 5 files are referenced via `initrd --name`
// 3. NO trust-store / driver / testsigning operations slip in
let entry_id = entry["id"].as_str().unwrap();
let url = format!("/boot/{entry_id}.ipxe");
let (s, body) = get(&app, &url).await;
assert_eq!(s, StatusCode::OK);
let script = String::from_utf8(body).unwrap();
assert!(script.contains("kernel "), "missing kernel line:\n{script}");
assert!(script.contains("ipxe/wimboot"), "missing wimboot loader:\n{script}");
for tag in ["bootmgr", "bootmgr.efi", "bcd", "boot.sdi", "boot.wim"] {
assert!(
script.contains(&format!("initrd --name {tag}")),
"missing `initrd --name {tag}` line:\n{script}"
);
}
// Hard guarantees we never want to see in any client-facing script.
let lower = script.to_lowercase();
for forbidden in [
"bcdedit", "testsigning", "certutil", "test-signed",
"httpdisk", "/set testsigning",
] {
assert!(
!lower.contains(forbidden),
"forbidden trust-store operation `{forbidden}` in script:\n{script}"
);
}
}
#[tokio::test]
async fn host_binding_short_circuits_boot_menu() {
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
// Pin a MAC to the reserved local-hdd boot shortcut. `_local` is a
// built-in target so the upsert validator accepts it without
// requiring a real ISO.
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hosts")
.header("content-type", "application/json")
.body(Body::from(
r#"{"mac":"AA:BB:CC:00:00:01","target":"_local","label":"toms-laptop"}"#
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::CREATED);
// Hit /boot.ipxe with the bound MAC and assert we get the
// short-circuit chain instead of the menu.
let (s1, b1) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:01").await;
assert_eq!(s1, StatusCode::OK);
let body1 = String::from_utf8(b1).unwrap();
assert!(
body1.contains("per-MAC binding"),
"expected MAC short-circuit, got:\n{body1}"
);
assert!(body1.contains("/boot/_local.ipxe"));
// And a different MAC still gets the menu.
let (s2, b2) = get(&app, "/boot.ipxe?mac=ff:ff:ff:ff:ff:ff").await;
assert_eq!(s2, StatusCode::OK);
let body2 = String::from_utf8(b2).unwrap();
assert!(
body2.contains("menu") || body2.contains("Default"),
"expected interactive menu, got:\n{body2}"
);
}
#[tokio::test]
async fn metrics_endpoint_emits_prometheus_format() {
let (state, _dir) = build_state().await;
let app = build_router(state);
// Drive a couple of paths so counters move off zero.
let _ = get(&app, "/api/status").await;
let _ = get(&app, "/boot.ipxe").await;
let res = app
.clone()
.oneshot(Request::builder().uri("/metrics").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let ct = res.headers().get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
assert!(
ct.starts_with("text/plain"),
"wrong content-type: {ct}"
);
let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
let body = String::from_utf8(body.to_vec()).unwrap();
// Spot-check the must-have metric families.
for name in [
"pxeforge_dhcp_replies_total",
"pxeforge_tftp_transfers_total",
"pxeforge_http_requests_total",
"pxeforge_iso_count",
"pxeforge_uptime_seconds",
"pxeforge_build_info",
] {
assert!(body.contains(name), "missing metric {name} in:\n{body}");
}
// Each name appears exactly once as a `# TYPE` declaration.
for name in [
"pxeforge_dhcp_replies_total",
"pxeforge_iso_count",
] {
let count = body.matches(&format!("# TYPE {name}")).count();
assert_eq!(count, 1, "{name} TYPE line appears {count} times");
}
}
#[tokio::test] #[tokio::test]
async fn network_endpoint_exposes_dns_round_trip() { async fn network_endpoint_exposes_dns_round_trip() {
let (state, _dir) = build_state().await; let (state, _dir) = build_state().await;
+9 -1
View File
@@ -40,10 +40,18 @@ pub fn asset_bytes(name: &str) -> Option<Vec<u8>> {
IpxeAssets::get(name).map(|f| f.data.into_owned()) IpxeAssets::get(name).map(|f| f.data.into_owned())
} }
/// Same as [`asset_bytes`] but returns the embedded slice directly,
/// avoiding the heap copy when the caller only needs to read the
/// payload. Falls back to None for unknown names.
#[must_use]
pub fn asset_slice(name: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
IpxeAssets::get(name).map(|f| f.data)
}
/// Enumerate embedded asset filenames. Useful for startup logging so the /// Enumerate embedded asset filenames. Useful for startup logging so the
/// operator can immediately tell which architectures will work. /// operator can immediately tell which architectures will work.
pub fn list_assets() -> Vec<String> { pub fn list_assets() -> Vec<String> {
IpxeAssets::iter().map(|c| c.into_owned()).collect() IpxeAssets::iter().map(std::borrow::Cow::into_owned).collect()
} }
/// Log at startup which iPXE binaries are present and which are missing. /// Log at startup which iPXE binaries are present and which are missing.
+1 -1
View File
@@ -98,7 +98,7 @@ pub fn introspect(path: &Path) -> IntrospectionReport {
// that happens in the store after introspection. // that happens in the store after introspection.
let (k, i) = guess_kernel_initrd(report.family); let (k, i) = guess_kernel_initrd(report.family);
report.kernel_path = k.map(str::to_string); report.kernel_path = k.map(str::to_string);
report.initrd_paths = i.iter().map(|s| s.to_string()).collect(); report.initrd_paths = i.iter().map(std::string::ToString::to_string).collect();
report report
} }
+18 -10
View File
@@ -87,13 +87,17 @@ impl SmbManager {
/// Write out `smb.conf` for the currently-discovered shares. Safe to /// Write out `smb.conf` for the currently-discovered shares. Safe to
/// call while smbd is running — smbd reloads on SIGHUP. /// call while smbd is running — smbd reloads on SIGHUP.
pub fn write_conf(&self) -> std::io::Result<Vec<String>> { pub fn write_conf(&self) -> std::io::Result<Vec<String>> {
use std::fmt::Write as _;
std::fs::create_dir_all(&self.smb_dir)?; std::fs::create_dir_all(&self.smb_dir)?;
let shares = self.discover_shares(); let shares = self.discover_shares();
let mut conf = String::new(); let mut conf = String::new();
conf.push_str(SMB_CONF_GLOBAL); conf.push_str(SMB_CONF_GLOBAL);
for name in &shares { for name in &shares {
let path = self.smb_dir.join(name); let path = self.smb_dir.join(name);
conf.push_str(&format!( // Per-share block. `write!` to String never fails — the unwrap
// is provably unreachable, but expect() makes that explicit.
write!(
conf,
"\n[{name}]\n\ "\n[{name}]\n\
path = {}\n\ path = {}\n\
comment = PXEForge Windows install media ({name})\n\ comment = PXEForge Windows install media ({name})\n\
@@ -103,7 +107,8 @@ impl SmbManager {
browseable = yes\n\ browseable = yes\n\
available = yes\n", available = yes\n",
path.display(), path.display(),
)); )
.expect("writing to a String is infallible");
} }
let tmp = self.conf_path.with_extension("conf.tmp"); let tmp = self.conf_path.with_extension("conf.tmp");
std::fs::write(&tmp, conf)?; std::fs::write(&tmp, conf)?;
@@ -114,7 +119,7 @@ impl SmbManager {
/// Start smbd. No-op if already running. /// Start smbd. No-op if already running.
pub fn start(&self) -> SmbState { pub fn start(&self) -> SmbState {
let mut g = self.child.lock(); let mut g = self.child.lock();
if g.as_ref().map_or(false, |c| c.id() > 0) { if g.as_ref().is_some_and(|c| c.id() > 0) {
return self.state.lock().clone(); return self.state.lock().clone();
} }
if !smbd_present() { if !smbd_present() {
@@ -173,7 +178,10 @@ impl SmbManager {
} }
}; };
if let Some(c) = g.as_mut() { if let Some(c) = g.as_mut() {
let pid = c.id() as i32; // u32 -> i32 for libc::kill. We never spawn enough children
// for the pid to overflow i32; cast_signed makes the intent
// explicit and silences the lint.
let pid = c.id().cast_signed();
// SAFETY: libc::kill is FFI-safe; we pass a pid we own (returned // 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 // from `Child::id` above, the child is alive because we hold the
// Mutex guard `g`) and a well-defined signal constant. Return // Mutex guard `g`) and a well-defined signal constant. Return
@@ -211,7 +219,7 @@ fn smbd_present() -> bool {
false false
} }
const SMB_CONF_GLOBAL: &str = r#"[global] const SMB_CONF_GLOBAL: &str = r"[global]
workgroup = PXEFORGE workgroup = PXEFORGE
server min protocol = SMB2 server min protocol = SMB2
smb ports = 445 smb ports = 445
@@ -227,7 +235,7 @@ lock directory = /tmp
state directory = /tmp state directory = /tmp
cache directory = /tmp cache directory = /tmp
pid directory = /tmp pid directory = /tmp
"#; ";
/// Extract a Windows ISO at `iso_path` into `smb_dir/<slug>/`. Uses /// Extract a Windows ISO at `iso_path` into `smb_dir/<slug>/`. Uses
/// `7z` when available (most reliable for UDF + ISO9660 hybrid images); /// `7z` when available (most reliable for UDF + ISO9660 hybrid images);
@@ -271,10 +279,10 @@ pub fn extract_windows_iso(iso_path: &Path, smb_dir: &Path, slug: &str) -> std::
.arg(&target) .arg(&target)
.output()?; .output()?;
if out.status.success() { return Ok(target); } if out.status.success() { return Ok(target); }
return Err(std::io::Error::new( return Err(std::io::Error::other(format!(
std::io::ErrorKind::Other, "bsdtar failed: {}",
format!("bsdtar failed: {}", String::from_utf8_lossy(&out.stderr)), String::from_utf8_lossy(&out.stderr)
)); )));
} }
Err(std::io::Error::new( Err(std::io::Error::new(
std::io::ErrorKind::NotFound, std::io::ErrorKind::NotFound,
+12 -12
View File
@@ -19,9 +19,10 @@ use tokio::io::AsyncWriteExt;
/// `Nfs` entries point at a file inside a remote share that the /// `Nfs` entries point at a file inside a remote share that the
/// `NfsManager` is keeping mounted. We resolve the on-disk path lazily /// `NfsManager` is keeping mounted. We resolve the on-disk path lazily
/// in [`IsoStore::iso_path_for`] using the `nfs_root` set at startup. /// in [`IsoStore::iso_path_for`] using the `nfs_root` set at startup.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum IsoSource { pub enum IsoSource {
#[default]
Local, Local,
Nfs { Nfs {
mount_id: String, mount_id: String,
@@ -30,12 +31,6 @@ pub enum IsoSource {
}, },
} }
impl Default for IsoSource {
fn default() -> Self {
Self::Local
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IsoMeta { pub struct IsoMeta {
/// Stable slug used in URLs (derived from the uploaded filename). /// Stable slug used in URLs (derived from the uploaded filename).
@@ -156,7 +151,11 @@ impl IsoStore {
while let Some(e) = entries.next_entry().await? { while let Some(e) = entries.next_entry().await? {
let p = e.path(); let p = e.path();
if p.extension().and_then(|s| s.to_str()) != Some("json") { continue; } 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")) { if !p
.file_name()
.and_then(|s| s.to_str())
.is_some_and(|n| n.ends_with(".meta.json"))
{
continue; continue;
} }
if let Ok(text) = tokio::fs::read_to_string(&p).await { if let Ok(text) = tokio::fs::read_to_string(&p).await {
@@ -218,7 +217,8 @@ impl IsoStore {
pub fn list(&self) -> Vec<IsoMeta> { pub fn list(&self) -> Vec<IsoMeta> {
let g = self.inner.read(); let g = self.inner.read();
let mut v: Vec<_> = g.isos.values().cloned().collect(); let mut v: Vec<_> = g.isos.values().cloned().collect();
v.sort_by(|a, b| b.uploaded_at.cmp(&a.uploaded_at)); // Newest-first by upload time.
v.sort_by_key(|m| std::cmp::Reverse(m.uploaded_at));
v v
} }
@@ -403,9 +403,9 @@ fn linux_cmdline(family: DistroFamily, id: &str) -> String {
DistroFamily::OpenSuse => format!( DistroFamily::OpenSuse => format!(
"install={iso_url} netsetup=dhcp" "install={iso_url} netsetup=dhcp"
), ),
DistroFamily::Arch => format!( DistroFamily::Arch => {
"archiso_http_srv=${{base-url}}/iso/ archisobasedir=arch ip=dhcp copytoram" "archiso_http_srv=${base-url}/iso/ archisobasedir=arch ip=dhcp copytoram".to_string()
), }
DistroFamily::Alpine => format!( DistroFamily::Alpine => format!(
"alpine_repo=${{base-url}}/iso/{id}/ modloop=${{base-url}}/iso/{id}/boot/modloop-lts ip=dhcp" "alpine_repo=${{base-url}}/iso/{id}/ modloop=${{base-url}}/iso/{id}/boot/modloop-lts ip=dhcp"
), ),
+22 -5
View File
@@ -152,18 +152,35 @@ const WINPESHL_INI: &str = "[LaunchApps]\r\n\
/// Uses CRLF line endings because WinPE cmd.exe requires them for .cmd files /// Uses CRLF line endings because WinPE cmd.exe requires them for .cmd files
/// created on unix hosts. /// created on unix hosts.
fn render_startnet(host: &str, share: &str) -> String { fn render_startnet(host: &str, share: &str) -> String {
let mut s = String::new(); use std::fmt::Write as _;
let host = host.trim(); let host = host.trim();
let share = share.trim_matches('/'); let share = share.trim_matches('/');
let mut s = String::new();
// Windows-style CRLF; consumed verbatim by cmd.exe inside WinPE.
// Bootimus v0.1.58 lesson: surface `net use` errors instead of
// tight-looping on a blind retry. We retry but log every miss.
s.push_str("@echo off\r\n"); s.push_str("@echo off\r\n");
s.push_str("echo PXEForge WinPE bootstrap\r\n"); s.push_str("echo PXEForge WinPE bootstrap\r\n");
s.push_str("wpeinit\r\n"); s.push_str("wpeinit\r\n");
// v0.1.58: explicitly start Workstation before mapping the share —
// `net use` otherwise lazily inits SMB-client and races wpeinit.
s.push_str("net start Workstation >nul 2>&1\r\n");
s.push_str("ipconfig /renew\r\n"); s.push_str("ipconfig /renew\r\n");
s.push_str(&format!("echo Waiting for SMB server {host} to be reachable...\r\n")); writeln!(s, "echo Waiting for SMB server {host} to be reachable...\r").unwrap();
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")); writeln!(
s,
":waitsmb\r\nping -n 1 -w 500 {host} >nul && goto havenet\r\n\
timeout /t 2 /nobreak >nul\r\ngoto waitsmb\r"
)
.unwrap();
s.push_str(":havenet\r\n"); s.push_str(":havenet\r\n");
s.push_str(&format!("echo Mapping install media from \\\\{host}\\{share}...\r\n")); writeln!(s, "echo Mapping install media from \\\\{host}\\{share}...\r").unwrap();
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")); writeln!(
s,
":mapshare\r\nnet use Z: \\\\{host}\\{share} /user:guest \"\" /persistent:no && goto mapped\r\n\
timeout /t 3 /nobreak >nul\r\ngoto mapshare\r"
)
.unwrap();
s.push_str(":mapped\r\n"); s.push_str(":mapped\r\n");
s.push_str("echo Starting Windows Setup\r\n"); s.push_str("echo Starting Windows Setup\r\n");
s.push_str("Z:\\setup.exe\r\n"); s.push_str("Z:\\setup.exe\r\n");
+15 -2
View File
@@ -3,7 +3,10 @@
//! them concurrently. //! them concurrently.
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use pxeforge_core::{ClientRegistry, Config, DhcpMode, GateQueue, LogBus, LogBusLayer, SettingsStore}; use pxeforge_core::{
ClientRegistry, Config, DhcpMode, GateQueue, HostBindings, LogBus, LogBusLayer, Metrics,
SettingsStore,
};
use pxeforge_dhcp_proxy::DhcpProxyServer; use pxeforge_dhcp_proxy::DhcpProxyServer;
use pxeforge_http_api::{build_router, AppState}; use pxeforge_http_api::{build_router, AppState};
use pxeforge_iso_store::{IsoStore, NfsManager, SmbManager}; use pxeforge_iso_store::{IsoStore, NfsManager, SmbManager};
@@ -99,6 +102,8 @@ async fn main() -> anyhow::Result<()> {
let clients = ClientRegistry::new(); let clients = ClientRegistry::new();
let gates = GateQueue::new(); let gates = GateQueue::new();
let settings = SettingsStore::load_or_default(&config.paths.work_dir); let settings = SettingsStore::load_or_default(&config.paths.work_dir);
let hosts = HostBindings::load_or_default(&config.paths.work_dir);
let metrics = Metrics::new();
// Build the SMB manager unconditionally — it starts/stops on the // Build the SMB manager unconditionally — it starts/stops on the
// Windows toggle, not at process start. If the `smb_dir` isn't // Windows toggle, not at process start. If the `smb_dir` isn't
@@ -134,6 +139,8 @@ async fn main() -> anyhow::Result<()> {
clients: clients.clone(), clients: clients.clone(),
settings: settings.clone(), settings: settings.clone(),
gates: gates.clone(), gates: gates.clone(),
hosts: hosts.clone(),
metrics: metrics.clone(),
smb: Some(smb.clone()), smb: Some(smb.clone()),
nfs: nfs.clone(), nfs: nfs.clone(),
log_bus: log_bus.clone(), log_bus: log_bus.clone(),
@@ -153,7 +160,12 @@ async fn main() -> anyhow::Result<()> {
Ok::<_, anyhow::Error>(()) Ok::<_, anyhow::Error>(())
}); });
let tftp = TftpServer::new(config.server.tftp_bind, config.server.tftp_port, clients.clone()); let tftp = TftpServer::new(
config.server.tftp_bind,
config.server.tftp_port,
clients.clone(),
metrics.clone(),
);
let tftp_task = tokio::spawn(tftp.run()); let tftp_task = tokio::spawn(tftp.run());
let dhcp_task: tokio::task::JoinHandle<anyhow::Result<()>> = match config.network.dhcp_mode { let dhcp_task: tokio::task::JoinHandle<anyhow::Result<()>> = match config.network.dhcp_mode {
@@ -165,6 +177,7 @@ async fn main() -> anyhow::Result<()> {
our_ip, our_ip,
public_base_url.clone(), public_base_url.clone(),
clients.clone(), clients.clone(),
metrics.clone(),
); );
tokio::spawn(s.run()) tokio::spawn(s.run())
} }
+29 -12
View File
@@ -35,17 +35,24 @@ pub struct TftpServer {
bind: IpAddr, bind: IpAddr,
port: u16, port: u16,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: pxeforge_core::Metrics,
} }
impl TftpServer { impl TftpServer {
pub fn new(bind: IpAddr, port: u16, clients: Arc<ClientRegistry>) -> Self { pub fn new(
Self { bind, port, clients } bind: IpAddr,
port: u16,
clients: Arc<ClientRegistry>,
metrics: pxeforge_core::Metrics,
) -> Self {
Self { bind, port, clients, metrics }
} }
pub async fn run(self) -> anyhow::Result<()> { pub async fn run(self) -> anyhow::Result<()> {
let sock = bind_udp(self.bind, self.port)?; let sock = bind_udp(self.bind, self.port)?;
tracing::info!(target: "pxeforge::tftp", "TFTP listening on {}:{}", self.bind, self.port); tracing::info!(target: "pxeforge::tftp", "TFTP listening on {}:{}", self.bind, self.port);
let clients = self.clients.clone(); let clients = self.clients.clone();
let metrics = self.metrics.clone();
let mut buf = vec![0u8; 2048]; let mut buf = vec![0u8; 2048];
loop { loop {
let (n, from) = match sock.recv_from(&mut buf).await { let (n, from) = match sock.recv_from(&mut buf).await {
@@ -57,9 +64,11 @@ impl TftpServer {
}; };
let data = buf[..n].to_vec(); let data = buf[..n].to_vec();
let clients = clients.clone(); let clients = clients.clone();
let metrics = metrics.clone();
let bind_ip = self.bind; let bind_ip = self.bind;
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = handle_rrq(data, from, bind_ip, clients).await { if let Err(e) = handle_rrq(data, from, bind_ip, clients, metrics.clone()).await {
metrics.record_tftp_err();
tracing::warn!(target: "pxeforge::tftp", peer=%from, "handler error: {e}"); tracing::warn!(target: "pxeforge::tftp", peer=%from, "handler error: {e}");
} }
}); });
@@ -72,10 +81,10 @@ async fn handle_rrq(
peer: SocketAddr, peer: SocketAddr,
bind_ip: IpAddr, bind_ip: IpAddr,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: pxeforge_core::Metrics,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let req = match parse_rrq(&packet) { let Some(req) = parse_rrq(&packet) else {
Some(r) => r, return Ok(());
None => return Ok(()),
}; };
let Request { filename, options, .. } = req; let Request { filename, options, .. } = req;
@@ -149,7 +158,11 @@ async fn handle_rrq(
let total = file_bytes.len(); let total = file_bytes.len();
let mut offset: usize = 0; let mut offset: usize = 0;
let mut block_no: u16 = 1; let mut block_no: u16 = 1;
let mut needs_zero_final = false; // spec: if last data block == blksize, follow with empty DATA // Per RFC 1350: if the final data block is exactly blksize, the
// server must follow up with a zero-length DATA so the client knows
// the transfer has ended. The flag is set inside the loop and
// tested at end-of-transfer.
let needs_zero_final;
'transfer: loop { 'transfer: loop {
let window_start_offset = offset; let window_start_offset = offset;
@@ -181,7 +194,7 @@ async fn handle_rrq(
loop { loop {
match tokio::time::timeout(Duration::from_secs(3), recv_ack(&sock, peer)).await { 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(acked)) if acked == last_block_in_window => break,
Ok(Ok(_)) => continue, // stale ACK from an earlier block — ignore Ok(Ok(_)) => {} // stale ACK from an earlier block — ignore
Ok(Err(e)) => return Err(e), Ok(Err(e)) => return Err(e),
Err(_) => { Err(_) => {
tries += 1; tries += 1;
@@ -229,6 +242,7 @@ async fn handle_rrq(
} }
tracing::debug!(target: "pxeforge::tftp", peer=%peer, bytes=total, "transfer complete"); tracing::debug!(target: "pxeforge::tftp", peer=%peer, bytes=total, "transfer complete");
metrics.record_tftp_ok(total as u64);
Ok(()) Ok(())
} }
@@ -249,7 +263,7 @@ fn parse_rrq(pkt: &[u8]) -> Option<Request> {
let mode = read_cstr(&mut rest)?; let mode = read_cstr(&mut rest)?;
let mut options = Vec::new(); let mut options = Vec::new();
while !rest.is_empty() { while !rest.is_empty() {
let k = match read_cstr(&mut rest) { Some(s) => s, None => break }; let Some(k) = read_cstr(&mut rest) else { break };
if k.is_empty() { break; } if k.is_empty() { break; }
let v = read_cstr(&mut rest).unwrap_or_default(); let v = read_cstr(&mut rest).unwrap_or_default();
options.push((k.to_ascii_lowercase(), v)); options.push((k.to_ascii_lowercase(), v));
@@ -311,7 +325,7 @@ async fn recv_ack(sock: &UdpSocket, peer: SocketAddr) -> anyhow::Result<u16> {
let code = u16::from_be_bytes([buf[2], buf[3]]); let code = u16::from_be_bytes([buf[2], buf[3]]);
anyhow::bail!("client error {code}"); anyhow::bail!("client error {code}");
} }
_ => continue, _ => {}
} }
} }
} }
@@ -327,7 +341,7 @@ async fn wait_for_ack(
sock.send_to(to_retx, peer).await?; sock.send_to(to_retx, peer).await?;
match tokio::time::timeout(Duration::from_secs(3), recv_ack(sock, 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(b)) if b == expect_block => return Ok(true),
Ok(Ok(_)) => continue, Ok(Ok(_)) => {}
Ok(Err(_)) | Err(_) => { Ok(Err(_)) | Err(_) => {
tries += 1; tries += 1;
if tries > 5 { return Ok(false); } if tries > 5 { return Ok(false); }
@@ -385,7 +399,10 @@ mod tests {
fn parses_rrq_with_options() { fn parses_rrq_with_options() {
// RRQ "snponly.efi" mode "octet" blksize=1468 tsize=0 // RRQ "snponly.efi" mode "octet" blksize=1468 tsize=0
let mut pkt = vec![0, OP_RRQ as u8]; let mut pkt = vec![0, OP_RRQ as u8];
pkt.extend_from_slice(b"snponly.efi\0octet\0blksize\01468\0tsize\00\0"); // The single-digit `\0` escapes here are NUL terminators between
// TFTP option name/value pairs — using `\x00` to dodge clippy's
// "octal-looking escape" lint.
pkt.extend_from_slice(b"snponly.efi\x00octet\x00blksize\x001468\x00tsize\x000\x00");
let r = parse_rrq(&pkt).unwrap(); let r = parse_rrq(&pkt).unwrap();
assert_eq!(r.filename, "snponly.efi"); assert_eq!(r.filename, "snponly.efi");
assert_eq!(r.mode, "octet"); assert_eq!(r.mode, "octet");
+81
View File
@@ -0,0 +1,81 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" fill="none">
<title>PXEForge — forging</title>
<defs>
<linearGradient id="afBody" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#aab3c2"/>
<stop offset="55%" stop-color="#7d8696"/>
<stop offset="100%" stop-color="#525a6b"/>
</linearGradient>
<linearGradient id="afFace" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#cdd5e1"/>
<stop offset="100%" stop-color="#9aa3b3"/>
</linearGradient>
<linearGradient id="afBase" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#3b4252"/>
<stop offset="100%" stop-color="#252a36"/>
</linearGradient>
<radialGradient id="afSpark" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#fff5b8" stop-opacity="1"/>
<stop offset="40%" stop-color="#ff9a3a" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#ff5a18" stop-opacity="0"/>
</radialGradient>
<radialGradient id="afEmber" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#ffd47a" stop-opacity="1"/>
<stop offset="100%" stop-color="#ff7322" stop-opacity="0"/>
</radialGradient>
</defs>
<!-- Anvil shifted down so sparks have room to rise above -->
<g transform="translate(0,40)">
<!-- Horn + face -->
<path d="M14 36 L72 30 L162 30 L162 46 L72 46 Z"
fill="url(#afFace)" stroke="#1d2330" stroke-width="2.4" stroke-linejoin="round"/>
<!-- Body / waist -->
<path d="M70 46 L160 46 L142 70 L88 70 Z"
fill="url(#afBody)" stroke="#1d2330" stroke-width="2.4" stroke-linejoin="round"/>
<!-- Pillar -->
<rect x="92" y="70" width="46" height="26" fill="url(#afBody)"
stroke="#1d2330" stroke-width="2.4"/>
<!-- Base -->
<path d="M62 96 L168 96 L160 110 L70 110 Z"
fill="url(#afBase)" stroke="#0d1018" stroke-width="2.4" stroke-linejoin="round"/>
<line x1="74" y1="34" x2="158" y2="34" stroke="#e6ecf5" stroke-width="1.2" opacity="0.7"/>
<!-- Soft underglow on top face where the sparks land -->
<ellipse cx="115" cy="33" rx="42" ry="6" fill="url(#afEmber)" opacity="0.55">
<animate attributeName="opacity" values="0.35;0.7;0.35"
dur="1.6s" repeatCount="indefinite"/>
</ellipse>
</g>
<!-- Sparks. SMIL animations only — no JS, no CSS needed. Each spark
rises, fades, restarts at a staggered delay for an organic feel. -->
<g class="sparks">
<circle cx="116" cy="62" r="3.4" fill="url(#afSpark)" opacity="0">
<animate attributeName="cy" from="62" to="14" dur="1.4s" repeatCount="indefinite"/>
<animate attributeName="cx" values="116;112;120;116" dur="1.4s" repeatCount="indefinite"/>
<animate attributeName="r" values="2;3.6;1.4" dur="1.4s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;0" dur="1.4s" repeatCount="indefinite"/>
</circle>
<circle cx="105" cy="62" r="2.4" fill="url(#afSpark)" opacity="0">
<animate attributeName="cy" from="62" to="22" dur="1.7s" begin="0.25s" repeatCount="indefinite"/>
<animate attributeName="cx" values="105;101;108;104" dur="1.7s" begin="0.25s" repeatCount="indefinite"/>
<animate attributeName="r" values="1.6;2.8;1" dur="1.7s" begin="0.25s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;0" dur="1.7s" begin="0.25s" repeatCount="indefinite"/>
</circle>
<circle cx="125" cy="62" r="2.8" fill="url(#afSpark)" opacity="0">
<animate attributeName="cy" from="62" to="6" dur="1.9s" begin="0.55s" repeatCount="indefinite"/>
<animate attributeName="cx" values="125;130;121;127" dur="1.9s" begin="0.55s" repeatCount="indefinite"/>
<animate attributeName="r" values="1.8;3.2;1.2" dur="1.9s" begin="0.55s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;0" dur="1.9s" begin="0.55s" repeatCount="indefinite"/>
</circle>
<circle cx="113" cy="62" r="2" fill="url(#afEmber)" opacity="0">
<animate attributeName="cy" from="62" to="32" dur="1.2s" begin="0.9s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;0.9;0" dur="1.2s" begin="0.9s" repeatCount="indefinite"/>
</circle>
<circle cx="132" cy="62" r="2.2" fill="url(#afSpark)" opacity="0">
<animate attributeName="cy" from="62" to="20" dur="1.5s" begin="1.2s" repeatCount="indefinite"/>
<animate attributeName="cx" values="132;138;128" dur="1.5s" begin="1.2s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;0" dur="1.5s" begin="1.2s" repeatCount="indefinite"/>
</circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

+194 -100
View File
@@ -1,35 +1,66 @@
/* PXEForge web UI — Netbox-style layout, fully offline. /* PXEForge web UI — Netbox-style minimal layout, fully offline.
* Design tokens are CSS variables so a later phase can re-theme without *
* touching markup or JS. */ * Theme tokens live on `:root` (dark default) and `:root[data-theme=light]`.
* Both palettes share variable *names*, so component CSS uses
* `var(--bg)` regardless and the toggle in the topbar just flips the
* data-attribute. No JS-side recolouring, no React re-renders, no FOUC
* (the inline script in index.html paints the right theme before main
* CSS lands). */
:root { :root {
--bg: #0b1018; /* Dark palette (default). */
--bg-panel: #121826; --bg: #0b1018;
--bg-panel-2: #1a2334; --bg-panel: #121826;
--bg-elev: #223047; --bg-panel-2: #1a2334;
--fg: #e4e8ef; --bg-elev: #223047;
--fg-dim: #8a94a7; --fg: #e4e8ef;
--fg-dimmer: #5a6379; --fg-dim: #8a94a7;
--accent: #00d4b4; /* Netbox-ish teal */ --fg-dimmer: #5a6379;
--accent-dim: #07a38c; --accent: #00d4b4; /* Netbox-ish teal */
--warn: #ffb347; --accent-dim: #07a38c;
--err: #ef6e6e; --warn: #ffb347;
--ok: #4ade80; --err: #ef6e6e;
--border: #223047; --ok: #4ade80;
--border: #223047;
--border-soft: #172033; --border-soft: #172033;
--radius: 6px; --terminal-bg: #06090e;
--shadow-card: 0 1px 0 rgba(255,255,255,0.02), 0 8px 24px rgba(0,0,0,0.25);
--radius: 6px;
--radius-lg: 10px; --radius-lg: 10px;
--sidebar-w: 240px; --sidebar-w: 240px;
--topbar-h: 54px; --topbar-h: 56px;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
} }
:root[data-theme="light"] {
/* Light palette — high-contrast neutral, accent unchanged for brand
consistency. Designed against Netbox Labs's reference screenshot:
near-white surfaces, soft grey dividers, dark text. */
--bg: #f6f8fb;
--bg-panel: #ffffff;
--bg-panel-2: #f0f3f8;
--bg-elev: #e6ebf2;
--fg: #1c2330;
--fg-dim: #5a6377;
--fg-dimmer: #95a0b3;
--accent: #00b89c;
--accent-dim: #008b73;
--warn: #b67016;
--err: #c63a3a;
--ok: #1f9b54;
--border: #d8dde6;
--border-soft: #e7eaf0;
--terminal-bg: #0d1219; /* terminal stays dark even in light mode */
--shadow-card: 0 1px 0 rgba(0,0,0,0.02), 0 6px 18px rgba(20,28,52,0.06);
}
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { height: 100%; } html, body { height: 100%; }
body { body {
margin: 0; font-family: var(--sans); font-size: 14px; line-height: 1.5; margin: 0; font-family: var(--sans); font-size: 14px; line-height: 1.5;
background: var(--bg); color: var(--fg); background: var(--bg); color: var(--fg);
transition: background 0.16s ease, color 0.16s ease;
} }
a { color: var(--accent); text-decoration: none; } a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
@@ -62,15 +93,11 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
.sidebar .brand strong { font-size: 16px; letter-spacing: 0.4px; } .sidebar .brand strong { font-size: 16px; letter-spacing: 0.4px; }
.sidebar .brand .sub { color: var(--fg-dim); font-size: 11px; } .sidebar .brand .sub { color: var(--fg-dim); font-size: 11px; }
.sidebar nav { padding: 10px 0; flex: 1; overflow-y: auto; } .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 { .sidebar nav a {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
padding: 7px 18px; color: var(--fg); font-size: 13.5px; padding: 8px 18px; color: var(--fg); font-size: 13.5px;
border-left: 2px solid transparent; border-left: 2px solid transparent;
cursor: pointer;
} }
.sidebar nav a:hover { background: var(--bg-panel-2); text-decoration: none; } .sidebar nav a:hover { background: var(--bg-panel-2); text-decoration: none; }
.sidebar nav a.active { .sidebar nav a.active {
@@ -96,7 +123,7 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
.topbar { .topbar {
grid-area: topbar; grid-area: topbar;
display: flex; align-items: center; display: flex; align-items: center;
padding: 0 20px; gap: 18px; padding: 0 20px; gap: 14px;
background: var(--bg-panel); background: var(--bg-panel);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
@@ -104,23 +131,31 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
margin: 0; font-size: 15px; font-weight: 600; margin: 0; font-size: 15px; font-weight: 600;
color: var(--fg); letter-spacing: 0.2px; 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 .spacer { flex: 1; }
.topbar .chip { .topbar .chip {
background: var(--bg-panel-2); border: 1px solid var(--border); background: var(--bg-panel-2); border: 1px solid var(--border);
color: var(--fg-dim); font-size: 12px; color: var(--fg-dim); font-size: 12px;
padding: 4px 10px; border-radius: 12px; padding: 4px 10px; border-radius: 12px;
white-space: nowrap;
} }
.topbar .chip strong { color: var(--fg); font-weight: 600; } .topbar .chip strong { color: var(--fg); font-weight: 600; }
/* Theme toggle button. Two glyphs stacked; CSS swaps which is visible
based on the active theme. Keeps the layout stable when toggling. */
.theme-toggle {
display: inline-flex; align-items: center; justify-content: center;
width: 36px; height: 32px;
background: transparent; color: var(--fg);
border: 1px solid var(--border); border-radius: 8px;
cursor: pointer; padding: 0;
transition: background 0.15s ease, border-color 0.15s ease;
}
.theme-toggle:hover { background: var(--bg-panel-2); border-color: var(--accent); }
.theme-toggle .t-sun { display: none; }
.theme-toggle .t-moon { display: inline; }
:root[data-theme="light"] .theme-toggle .t-sun { display: inline; }
:root[data-theme="light"] .theme-toggle .t-moon { display: none; }
/* ── Main content ─────────────────────────────────────────────────── */ /* ── Main content ─────────────────────────────────────────────────── */
.main { .main {
@@ -142,6 +177,7 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
overflow: hidden; overflow: hidden;
box-shadow: var(--shadow-card);
} }
.card > header { .card > header {
padding: 12px 16px; padding: 12px 16px;
@@ -153,9 +189,7 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
.card > header .sub { color: var(--fg-dim); font-size: 12px; margin-left: auto; } .card > header .sub { color: var(--fg-dim); font-size: 12px; margin-left: auto; }
.card .body { padding: 16px; } .card .body { padding: 16px; }
.stat { .stat { padding: 16px; }
padding: 16px;
}
.stat .label { color: var(--fg-dim); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.8px; } .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 .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; } .stat .trend { font-size: 12px; color: var(--fg-dim); margin-top: 4px; }
@@ -179,12 +213,12 @@ td.num { text-align: right; font-variant-numeric: tabular-nums; }
display: inline-block; display: inline-block;
padding: 2px 8px; border-radius: 10px; padding: 2px 8px; border-radius: 10px;
font-size: 11px; font-weight: 600; font-size: 11px; font-weight: 600;
background: #1b3148; color: #a2c5e8; background: var(--bg-elev); color: var(--fg-dim);
} }
.tag.ok { background: #103428; color: var(--ok); } .tag.ok { background: color-mix(in srgb, var(--ok) 22%, transparent); color: var(--ok); }
.tag.warn { background: #3a2a10; color: var(--warn); } .tag.warn { background: color-mix(in srgb, var(--warn) 22%, transparent); color: var(--warn); }
.tag.err { background: #3a1515; color: var(--err); } .tag.err { background: color-mix(in srgb, var(--err) 22%, transparent); color: var(--err); }
.tag.accent { background: #072f29; color: var(--accent); } .tag.accent { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--accent); }
.tag.arch { text-transform: uppercase; } .tag.arch { text-transform: uppercase; }
/* ── Forms ────────────────────────────────────────────────────────── */ /* ── Forms ────────────────────────────────────────────────────────── */
@@ -194,12 +228,13 @@ button, .btn {
border: 0; border-radius: var(--radius); border: 0; border-radius: var(--radius);
padding: 7px 14px; font: inherit; font-weight: 600; padding: 7px 14px; font: inherit; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background 0.12s ease;
} }
button:hover, .btn:hover { background: var(--accent-dim); color: #fff; } button:hover, .btn:hover { background: var(--accent-dim); color: #fff; }
button.ghost { background: transparent; color: var(--fg); border: 1px solid var(--border); } button.ghost { background: transparent; color: var(--fg); border: 1px solid var(--border); }
button.ghost:hover { background: var(--bg-panel-2); color: var(--fg); } button.ghost:hover { background: var(--bg-panel-2); color: var(--fg); }
button.danger { background: transparent; color: var(--err); border: 1px solid #4a1f1f; } button.danger { background: transparent; color: var(--err); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); }
button.danger:hover { background: #2a0b0b; color: var(--err); } button.danger:hover { background: color-mix(in srgb, var(--err) 14%, transparent); color: var(--err); }
label.field { label.field {
display: grid; gap: 4px; margin-bottom: 14px; display: grid; gap: 4px; margin-bottom: 14px;
@@ -238,12 +273,114 @@ label.check input { accent-color: var(--accent); }
background: var(--bg-panel-2); background: var(--bg-panel-2);
} }
.drop strong { color: var(--accent); } .drop strong { color: var(--accent); }
/* Plain progress bar (used for ISO uploads). */
.progress { height: 6px; background: var(--bg-panel-2); border-radius: 3px; overflow: hidden; margin-top: 12px; display: none; } .progress { height: 6px; background: var(--bg-panel-2); border-radius: 3px; overflow: hidden; margin-top: 12px; display: none; }
.progress.active { display: block; } .progress.active { display: block; }
.progress .bar { height: 100%; width: 0%; background: var(--accent); transition: width .25s; } .progress .bar { height: 100%; width: 0%; background: var(--accent); transition: width .25s; }
/* ── Gate queue "horse race" visual ───────────────────────────────── */ /* ── Forge progress widget ─────────────────────────────────────────
Anvil-with-sparks animation paired with a horizontal progress bar.
Used on the Forge Gate tab to give a sense of the "in-flight"
imaging count without having to read a number. */
.forge-progress {
display: flex; align-items: center; gap: 16px;
padding: 16px;
}
.forge-progress .anvil {
width: 64px; height: 64px; flex: none;
background: url("/assets/anvil-forge.svg") no-repeat center / contain;
filter: drop-shadow(0 0 14px color-mix(in srgb, var(--warn) 40%, transparent));
}
.forge-progress .info { flex: 1; min-width: 0; }
.forge-progress .info .label {
font-size: 12.5px; color: var(--fg-dim); margin-bottom: 6px;
}
.forge-progress .bar-track {
height: 8px; background: var(--bg-elev); border-radius: 4px;
overflow: hidden; position: relative;
}
.forge-progress .bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--warn), var(--accent));
width: 0%;
transition: width 0.4s ease;
position: relative;
}
.forge-progress .bar-fill::after {
/* Subtle moving sheen so the bar feels alive even at 0% movement. */
content: ""; position: absolute; inset: 0;
background: linear-gradient(
90deg,
rgba(255,255,255,0) 0%,
rgba(255,255,255,0.18) 50%,
rgba(255,255,255,0) 100%);
animation: forge-sheen 1.6s linear infinite;
}
@keyframes forge-sheen {
from { transform: translateX(-100%); }
to { transform: translateX(100%); }
}
.forge-progress.idle .anvil { filter: none; opacity: 0.45; }
.forge-progress.idle .bar-fill::after { animation: none; }
/* ── Page-load anvil ──────────────────────────────────────────────── */
.loader {
display: flex; flex-direction: column; align-items: center; gap: 12px;
padding: 40px 20px;
color: var(--fg-dim);
}
.loader .anvil {
width: 110px; height: 110px;
background: url("/assets/anvil-forge.svg") no-repeat center / contain;
}
/* ── Top bar readiness chip ──────────────────────────────────────── */
.chip.ready { background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok); border-color: color-mix(in srgb, var(--ok) 35%, transparent); }
.chip.notready { background: color-mix(in srgb, var(--err) 18%, transparent); color: var(--err); border-color: color-mix(in srgb, var(--err) 35%, transparent); }
.chip.warming { background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); border-color: color-mix(in srgb, var(--warn) 35%, transparent); }
/* ── 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 ────────────────── */
tr.unbootable td { background: color-mix(in srgb, var(--warn) 7%, transparent) !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: color-mix(in srgb, #7cd3ff 18%, var(--bg-elev));
color: color-mix(in srgb, #7cd3ff 90%, var(--fg)); }
/* ── NFS rows ────────────────────────────────────────────────────── */
.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. */
.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; }
@media (max-width: 900px) { .form-row { grid-template-columns: 1fr; } }
/* ── Gate queue "horse race" visual ──────────────────────────────── */
.gate-track { .gate-track {
display: grid; gap: 6px; display: grid; gap: 6px;
padding: 10px 0; padding: 10px 0;
@@ -266,59 +403,16 @@ label.check input { accent-color: var(--accent); }
.msg.err { color: var(--err); } .msg.err { color: var(--err); }
.msg.ok { color: var(--ok); } .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 pane ──────────────────────────────────────────────── */
.terminal { .terminal {
display: flex; flex-direction: column; display: flex; flex-direction: column;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: #06090e; background: var(--terminal-bg);
overflow: hidden; overflow: hidden;
height: calc(100vh - var(--topbar-h) - 90px); height: calc(100vh - var(--topbar-h) - 90px);
min-height: 480px; min-height: 480px;
box-shadow: var(--shadow-card);
} }
.terminal .pane { .terminal .pane {
flex: 1; overflow: auto; flex: 1; overflow: auto;
@@ -330,37 +424,37 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
.terminal .pane .lvl-error { color: var(--err); } .terminal .pane .lvl-error { color: var(--err); }
.terminal .pane .lvl-warn { color: var(--warn); } .terminal .pane .lvl-warn { color: var(--warn); }
.terminal .pane .lvl-info { color: #cfd6e2; } .terminal .pane .lvl-info { color: #cfd6e2; }
.terminal .pane .lvl-debug { color: var(--fg-dim); } .terminal .pane .lvl-debug { color: #8b94a8; }
.terminal .pane .lvl-trace { color: var(--fg-dimmer); } .terminal .pane .lvl-trace { color: #5a6379; }
.terminal .pane .ts { color: var(--fg-dimmer); } .terminal .pane .ts { color: #5a6379; }
.terminal .pane .tg { color: #7cd3ff; } .terminal .pane .tg { color: #7cd3ff; }
.terminal .pane .echo { color: var(--accent); } .terminal .pane .echo { color: var(--accent); }
.terminal .input-row { .terminal .input-row {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
padding: 8px 14px; padding: 8px 14px;
background: #0a0e15; background: #0a0e15;
border-top: 1px solid var(--border); border-top: 1px solid #1d2330;
} }
.terminal .input-row .prompt { color: var(--accent); font-family: var(--mono); } .terminal .input-row .prompt { color: var(--accent); font-family: var(--mono); }
.terminal .input-row input { .terminal .input-row input {
flex: 1; background: transparent; border: 0; color: var(--fg); flex: 1; background: transparent; border: 0; color: #e4e8ef;
font: inherit; font-family: var(--mono); font-size: 13px; font: inherit; font-family: var(--mono); font-size: 13px;
outline: none; padding: 4px 0; outline: none; padding: 4px 0;
} }
.terminal .toolbar { .terminal .toolbar {
display: flex; gap: 8px; align-items: center; display: flex; gap: 8px; align-items: center;
padding: 8px 14px; padding: 8px 14px;
background: var(--bg-panel-2); background: #0a0e15;
border-bottom: 1px solid var(--border); border-bottom: 1px solid #1d2330;
font-size: 12px; color: var(--fg-dim); font-size: 12px; color: #8a94a7;
} }
.terminal .toolbar .right { margin-left: auto; display: flex; gap: 6px; } .terminal .toolbar .right { margin-left: auto; display: flex; gap: 6px; }
.terminal .toolbar button { .terminal .toolbar button {
padding: 3px 9px; font-size: 11px; padding: 3px 9px; font-size: 11px;
background: transparent; color: var(--fg-dim); border: 1px solid var(--border); background: transparent; color: #8a94a7; border: 1px solid #1d2330;
font-weight: 500; font-weight: 500;
} }
.terminal .toolbar button:hover { color: var(--fg); background: var(--bg-elev); } .terminal .toolbar button:hover { color: #e4e8ef; background: #1d2330; }
/* ── About card ─────────────────────────────────────────────────── */ /* ── About card ─────────────────────────────────────────────────── */
.about-hero { padding: 20px 24px; } .about-hero { padding: 20px 24px; }
+160 -6
View File
@@ -75,6 +75,26 @@
return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)}); return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
} }
// Animated "anvil + progress bar" widget. Built once here and inlined
// wherever a card wants to convey "an image is being forged onto the
// bench right now." Used on the dashboard and on the Forge Gate.
function forgeProgressWidget(imaging, gateTotal) {
const total = Math.max(gateTotal, imaging, 1);
const pct = imaging > 0 ? Math.round((imaging / total) * 100) : 0;
const root = el('div', {class: 'forge-progress' + (imaging === 0 ? ' idle' : '')}, [
el('div', {class: 'anvil', 'aria-hidden': 'true'}),
el('div', {class: 'info'}, [
el('div', {class: 'label'},
imaging === 0
? 'No active imaging'
: (imaging + ' of ' + total + ' device' + (total === 1 ? '' : 's') + ' being forged')),
el('div', {class: 'bar-track'},
el('div', {class: 'bar-fill', style: 'width:' + pct + '%'})),
]),
]);
return root;
}
// Categorize an ISO row's "bootable now" status — drives the amber // Categorize an ISO row's "bootable now" status — drives the amber
// tint borrowed from Bootimus v0.1.62. Returns {ok, reason}. // tint borrowed from Bootimus v0.1.62. Returns {ok, reason}.
function bootability(iso, settings) { function bootability(iso, settings) {
@@ -112,11 +132,14 @@
ipxeOk ? 'Bootloaders bundled, accepting clients' ipxeOk ? 'Bootloaders bundled, accepting clients'
: 'No iPXE binaries bundled'), : 'No iPXE binaries bundled'),
])), ])),
el('div', {class: 'card'}, el('div', {class: 'stat'}, [ el('div', {class: 'card'}, [
el('div', {class: 'label'}, 'Imaging now'), el('div', {class: 'stat', style: 'padding-bottom:0'}, [
el('div', {class: 'value'}, String(status.imaging_count || 0)), el('div', {class: 'label'}, 'Imaging now'),
el('div', {class: 'trend'}, (status.waiting_count || 0) + ' waiting at gate'), el('div', {class: 'value'}, String(status.imaging_count || 0)),
])), el('div', {class: 'trend'}, (status.waiting_count || 0) + ' waiting at gate'),
]),
forgeProgressWidget(status.imaging_count || 0, status.gate_count || 0),
]),
el('div', {class: 'card'}, el('div', {class: 'stat'}, [ el('div', {class: 'card'}, el('div', {class: 'stat'}, [
el('div', {class: 'label'}, 'Images available'), el('div', {class: 'label'}, 'Images available'),
el('div', {class: 'value'}, String(isos.length)), el('div', {class: 'value'}, String(isos.length)),
@@ -268,7 +291,13 @@
: el('div', {class:'empty'}, : el('div', {class:'empty'},
'No clients at the gate. Boot a client and choose "Gated Deployment" in the PXE menu.'); 'No clients at the gate. Boot a client and choose "Gated Deployment" in the PXE menu.');
const imaging = gates.filter(g => g.assigned_target).length;
return el('div', {class:'grid'}, [ return el('div', {class:'grid'}, [
el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Forge')),
forgeProgressWidget(imaging, gates.length),
]),
el('div', {class:'card'}, [ el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Launch an image across the gate')), el('header', {}, el('h2', {}, 'Launch an image across the gate')),
el('div', {class:'body'}, [ el('div', {class:'body'}, [
@@ -476,6 +505,99 @@
]); ]);
}, },
hosts: async () => {
const [{ hosts = [] }, isos] = await Promise.all([
getJSON('/api/hosts'), getJSON('/api/isos'),
]);
const targets = isos.flatMap(i => i.boot_entries.map(e => ({
id: e.id, title: e.title + ' — ' + familyLabel(i.introspection.family),
})));
// Reserved menu shortcuts that the operator might want to bind.
const reserved = [
{id: '_local', title: '↳ Boot from Local HDD (built-in)'},
{id: '_gate', title: '↳ Gated Deployment (built-in)'},
{id: '_tools_menu', title: '↳ Tools menu (built-in)'},
];
const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff', spellcheck:'false'});
const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'});
const targetSel = el('select', {},
[el('option', {value:''}, '— choose a target —')]
.concat(reserved.map(t => el('option', {value: t.id}, t.title)))
.concat(targets.map(t => el('option', {value: t.id}, t.title))));
const msg = el('div', {class:'msg'});
const upsertBtn = el('button', {onclick: async () => {
if (!macInput.value || !targetSel.value) {
msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
}
const r = await postJSON('/api/hosts', {
mac: macInput.value, target: targetSel.value, label: labelInput.value,
});
if (r.ok) {
msg.textContent = 'Saved.'; msg.className = 'msg ok';
render('hosts');
} else {
const t = await r.text();
msg.textContent = 'Save failed: ' + t; msg.className = 'msg err';
}
}}, 'Bind MAC to target');
const rows = hosts.map(h => el('tr', {}, [
el('td', {class:'mono'}, h.mac),
el('td', {}, h.label || el('span', {class:'tag'}, '(unlabeled)')),
el('td', {class:'mono'}, h.target),
el('td', {}, fmtAgo(h.updated_at)),
el('td', {style:'text-align:right'},
el('button', {class:'danger', onclick: async () => {
if (!confirm('Remove binding for ' + h.mac + '?')) return;
await fetch('/api/hosts/' + encodeURIComponent(h.mac), {method:'DELETE'});
render('hosts');
}}, 'Remove')),
]));
const table = hosts.length
? el('table', {}, [
el('thead', {}, el('tr', {}, [
el('th',{},'MAC'), el('th',{},'Label'),
el('th',{},'Target'), el('th',{},'Updated'), el('th',{},''),
])),
el('tbody', {}, rows),
])
: el('div', {class:'empty'}, 'No host bindings yet. Pin a MAC to a boot target to skip the menu for that machine.');
return el('div', {class:'grid'}, [
el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Pin MAC to boot target')),
el('div', {class:'body'}, [
el('div', {class:'form-row'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'MAC address'), macInput]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]),
el('label', {class:'field', style:'grid-column:1 / -1'}, [
el('span', {class:'name'}, 'Target'),
targetSel,
el('span', {class:'hint'},
'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'),
]),
]),
upsertBtn, msg,
el('p', {class:'msg', style:'margin-top:14px'},
'When a client with a bound MAC requests boot.ipxe, PXEForge ' +
'short-circuits past the interactive menu and chains directly. ' +
'Inspired by Tinkerbell smee\'s MAC-prepended URL pattern.'),
]),
]),
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Bound hosts'),
el('span', {class:'sub'}, hosts.length + ' binding' + (hosts.length === 1 ? '' : 's')),
]),
table,
]),
]);
},
terminal: async () => { terminal: async () => {
// Two-pane layout: live log on top (auto-scrolling), command line // Two-pane layout: live log on top (auto-scrolling), command line
// on bottom. Mirrors the Minecraft-server console feel from the // on bottom. Mirrors the Minecraft-server console feel from the
@@ -638,10 +760,35 @@
network: 'Network', network: 'Network',
gate: 'Forge Gate', gate: 'Forge Gate',
storage: 'Storage', storage: 'Storage',
hosts: 'Hosts',
terminal: 'Terminal', terminal: 'Terminal',
about: 'About', about: 'About',
}; };
// Theme toggle. The data-attribute is set on <html> by the inline
// script in index.html before paint; we just flip it here and persist.
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('pxeforge-theme', theme); } catch {}
}
function currentTheme() {
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
}
document.addEventListener('DOMContentLoaded', () => {
const btn = $('#theme-toggle');
if (btn) {
btn.addEventListener('click', () => {
applyTheme(currentTheme() === 'light' ? 'dark' : 'light');
});
}
});
// Keyboard shortcut: T toggles theme (skip when typing in an input).
document.addEventListener('keydown', (e) => {
if (e.key !== 't' && e.key !== 'T') return;
if (/^(INPUT|TEXTAREA|SELECT)$/.test((e.target && e.target.tagName) || '')) return;
applyTheme(currentTheme() === 'light' ? 'dark' : 'light');
});
let currentBody = null; let currentBody = null;
async function render(view) { async function render(view) {
@@ -654,7 +801,13 @@
try { currentBody._cleanup(); } catch (e) { /* ignore */ } try { currentBody._cleanup(); } catch (e) { /* ignore */ }
} }
root.innerHTML = ''; root.innerHTML = '';
root.appendChild(el('div', {class:'msg'}, 'Loading…')); // Animated anvil loader. Replaces the old text "Loading…" so the
// user gets a sense of "the forge is heating up" instead of a flat
// spinner. The SVG itself drives all animation via SMIL, no JS.
root.appendChild(el('div', {class:'loader'}, [
el('div', {class:'anvil'}),
el('div', {}, 'Heating the forge…'),
]));
try { try {
const body = await views[view](); const body = await views[view]();
root.innerHTML = ''; root.innerHTML = '';
@@ -674,6 +827,7 @@
$$('[data-bind=iso_count],[data-bind=iso_count2]').forEach(n => n.textContent = String(s.iso_count)); $$('[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=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)); $$('[data-bind=gate_count],[data-bind=gate_count2]').forEach(n => n.textContent = String(s.gate_count));
$$('[data-bind=host_count]').forEach(n => n.textContent = String(s.host_bindings || 0));
const chip = $('[data-bind=ready_chip]'); const chip = $('[data-bind=ready_chip]');
if (chip) { if (chip) {
if (r.ok) { chip.textContent = '● ready'; chip.className = 'chip ready'; } if (r.ok) { chip.textContent = '● ready'; chip.className = 'chip ready'; }
+40 -1
View File
@@ -3,9 +3,24 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<title>PXEForge</title> <title>PXEForge</title>
<link rel="stylesheet" href="/assets/app.css" /> <link rel="stylesheet" href="/assets/app.css" />
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg" /> <link rel="icon" type="image/svg+xml" href="/assets/logo.svg" />
<!-- Theme is read from localStorage *before* paint to avoid the
dark→light flash on every navigation. Falls back to the OS
preference and finally to dark. -->
<script>
(function() {
try {
var stored = localStorage.getItem('pxeforge-theme');
var theme = stored || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
</head> </head>
<body> <body>
<div class="shell"> <div class="shell">
@@ -14,7 +29,7 @@
<img src="/assets/logo.svg" alt="" /> <img src="/assets/logo.svg" alt="" />
<div> <div>
<strong>PXEForge</strong> <strong>PXEForge</strong>
<div class="sub">v<span data-bind="version">0.1.0</span></div> <div class="sub">v<span data-bind="version">0.2.0</span></div>
</div> </div>
</div> </div>
<nav> <nav>
@@ -28,6 +43,10 @@
Storage Storage
<span class="count" data-bind="iso_count">0</span> <span class="count" data-bind="iso_count">0</span>
</a> </a>
<a data-view="hosts">
Hosts
<span class="count" data-bind="host_count">0</span>
</a>
<a data-view="terminal">Terminal</a> <a data-view="terminal">Terminal</a>
<a data-view="about">About</a> <a data-view="about">About</a>
</nav> </nav>
@@ -44,6 +63,26 @@
<span class="chip"><strong data-bind="iso_count2">0</strong>&nbsp;images</span> <span class="chip"><strong data-bind="iso_count2">0</strong>&nbsp;images</span>
<span class="chip"><strong data-bind="client_count2">0</strong>&nbsp;clients</span> <span class="chip"><strong data-bind="client_count2">0</strong>&nbsp;clients</span>
<span class="chip"><strong data-bind="gate_count2">0</strong>&nbsp;at gate</span> <span class="chip"><strong data-bind="gate_count2">0</strong>&nbsp;at gate</span>
<button id="theme-toggle" class="theme-toggle" type="button"
aria-label="Toggle light/dark theme" title="Toggle theme (T)">
<!-- Two glyphs; CSS shows whichever matches the active theme. -->
<svg class="t-sun" viewBox="0 0 24 24" width="18" height="18" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="12" cy="12" r="4.2"/>
<line x1="12" y1="2.5" x2="12" y2="5.5"/>
<line x1="12" y1="18.5" x2="12" y2="21.5"/>
<line x1="2.5" y1="12" x2="5.5" y2="12"/>
<line x1="18.5" y1="12" x2="21.5" y2="12"/>
<line x1="5.2" y1="5.2" x2="7.3" y2="7.3"/>
<line x1="16.7" y1="16.7" x2="18.8" y2="18.8"/>
<line x1="5.2" y1="18.8" x2="7.3" y2="16.7"/>
<line x1="16.7" y1="7.3" x2="18.8" y2="5.2"/>
</svg>
<svg class="t-moon" viewBox="0 0 24 24" width="18" height="18" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20.5 14A8 8 0 0 1 10 3.5 a8 8 0 1 0 10.5 10.5z"/>
</svg>
</button>
</header> </header>
<main class="main" id="view-root"></main> <main class="main" id="view-root"></main>
+11 -4
View File
@@ -23,7 +23,14 @@ pub fn app_css() -> &'static str { APP_CSS }
#[must_use] #[must_use]
pub fn logo_svg() -> &'static str { LOGO_SVG } pub fn logo_svg() -> &'static str { LOGO_SVG }
const INDEX_HTML: &str = include_str!("index.html"); /// Animated forging anvil — sparks rise + glow pulse. Used for the
const APP_CSS: &str = include_str!("app.css"); /// imaging-progress widget and any "I'm working" loading state. Pure
const APP_JS: &str = include_str!("app.js"); /// SVG + SMIL, no JS, no GIF.
const LOGO_SVG: &str = include_str!("logo.svg"); #[must_use]
pub fn anvil_forge_svg() -> &'static str { ANVIL_FORGE_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");
const ANVIL_FORGE_SVG: &str = include_str!("anvil-forge.svg");
+30 -12
View File
@@ -1,14 +1,32 @@
<svg viewBox="0 0 96 64" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 200 130" xmlns="http://www.w3.org/2000/svg" fill="none">
<title>PXEForge</title> <title>PXEForge</title>
<!-- Anvil body --> <defs>
<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"/> <linearGradient id="anvilBody" x1="0" y1="0" x2="0" y2="1">
<!-- Horn highlight --> <stop offset="0%" stop-color="#aab3c2"/>
<path d="M6 22 L20 22 L14 28 L6 28 Z" fill="#ffb066"/> <stop offset="55%" stop-color="#7d8696"/>
<!-- Stand + base --> <stop offset="100%" stop-color="#525a6b"/>
<rect x="34" y="50" width="20" height="4" fill="#3a1f08"/> </linearGradient>
<rect x="22" y="54" width="44" height="6" fill="#1c1107"/> <linearGradient id="anvilFace" x1="0" y1="0" x2="0" y2="1">
<!-- Subtle spark --> <stop offset="0%" stop-color="#cdd5e1"/>
<circle cx="86" cy="16" r="1.5" fill="#ffd79a"/> <stop offset="100%" stop-color="#9aa3b3"/>
<circle cx="90" cy="22" r="1" fill="#ffd79a"/> </linearGradient>
<circle cx="82" cy="12" r="1" fill="#ffd79a"/> <linearGradient id="anvilBase" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#3b4252"/>
<stop offset="100%" stop-color="#252a36"/>
</linearGradient>
</defs>
<!-- Horn (left point) + face (top) -->
<path d="M14 36 L72 30 L162 30 L162 46 L72 46 Z"
fill="url(#anvilFace)" stroke="#1d2330" stroke-width="2.4" stroke-linejoin="round"/>
<!-- Body / waist -->
<path d="M70 46 L160 46 L142 70 L88 70 Z"
fill="url(#anvilBody)" stroke="#1d2330" stroke-width="2.4" stroke-linejoin="round"/>
<!-- Base plinth -->
<path d="M62 96 L168 96 L160 110 L70 110 Z"
fill="url(#anvilBase)" stroke="#0d1018" stroke-width="2.4" stroke-linejoin="round"/>
<!-- Pillar between body and base -->
<rect x="92" y="70" width="46" height="26" fill="url(#anvilBody)"
stroke="#1d2330" stroke-width="2.4"/>
<!-- Highlight along top face -->
<line x1="74" y1="34" x2="158" y2="34" stroke="#e6ecf5" stroke-width="1.2" opacity="0.7"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 650 B

After

Width:  |  Height:  |  Size: 1.5 KiB

+75 -1
View File
@@ -256,7 +256,70 @@ tab is one click from the brand bar.
- Streaming uploads are already in place via axum multipart; the v0.1.62 - Streaming uploads are already in place via axum multipart; the v0.1.62
fix to "502 on big upload" doesn't apply. fix to "502 on big upload" doesn't apply.
## What's deferred to Phase 5 ## Phase 5 — pre-beta hardening
**Per-MAC host bindings** (`crates/core/src/host_bindings.rs`):
- New `HostBindings` registry maps a MAC → preferred `BootEntry::id`
(or one of the reserved menu shortcuts `_local`, `_gate`,
`_tools_menu`).
- Persisted to `<work_dir>/hosts.json`. Like `SettingsStore`, in-memory
is authoritative — disk corruption falls back to empty rather than
failing startup.
- Inspired by Tinkerbell `smee`'s MAC-prepended URL pattern. The DHCP
reply now embeds `?mac=${mac}` in the boot.ipxe URL; iPXE substitutes
the literal MAC client-side, so the HTTP layer can short-circuit
past the menu when a binding exists.
- `/api/hosts` GET / POST / DELETE drives the **Hosts** tab.
**Prometheus metrics** (`crates/core/src/metrics.rs`):
- Lock-free `AtomicU64`-backed counters + gauges. No `prometheus` /
`metrics-rs` dep — they bring a registry, runtime, and complexity
we don't need for a fixed set of metric families.
- Counters: DHCP replies (per arch label), DHCP declined, TFTP
transfers (per status label), TFTP bytes, HTTP requests (per route
label).
- Gauges: ISO count, client count, gate count, gate-imaging count,
NFS active mounts, uptime, build info.
- Exposed as plain Prometheus text at `/metrics`.
**Code cleanup pass**: clippy `--workspace --all-targets` is now
warning-free. Replaced `format!()`-into-`String` with
`std::fmt::Write::write!`, switched manual reverse comparators to
`Reverse`, fixed `map_or(false, …)``is_some_and`, and a handful of
other idiom fixes.
**UI overhaul** for the v0.2.0 pre-beta milestone:
- Light + dark themes via `:root[data-theme=light]` token swap.
Toggled by a top-right button or the `T` key. Persisted in
localStorage; pre-paint inline script avoids dark→light flash.
- New SVG logos: a refined anvil (`logo.svg`) and a SMIL-animated
`anvil-forge.svg` (rising sparks + pulsing underglow). Pure SVG —
no GIFs, no CSS keyframes for the sparks.
- "Forge progress" widget on the Dashboard and Forge Gate: animated
anvil paired with a `linear-gradient(warn → accent)` progress bar
with a moving sheen. Goes idle (greyscale, no sheen) at zero
imaging load.
- Loader replaced "Loading…" text with the same anvil.
- Sidebar gains a **Hosts** tab.
**Windows boot validation**:
- New integration test synthesizes an ISO9660 with the `SOURCES\BOOT.WIM`
sentinel, uploads it, and asserts:
1. introspection labels it `windows_pe` with `has_boot_wim=true`,
2. the boot entry is `BootKind::Wimboot` with all five canonical
files (`bootmgr`, `bootmgr.efi`, `bcd`, `boot.sdi`, `boot.wim`),
3. the rendered iPXE script chains wimboot with `initrd --name`
entries for each, and
4. **no** trust-store strings appear: `bcdedit`, `testsigning`,
`certutil`, `httpdisk`, `test-signed` are all explicitly
forbidden in the rendered output.
- WinPE bootstrap (`startnet.cmd`) now picks up Bootimus v0.1.58
fixes: explicit `net start Workstation` before `net use`, surfaces
errors instead of blind retries.
**Test count**: 66 → up from 56 in v0.1.0.
## What's deferred to Phase 6
- Full ISO9660 + Joliet + Rock Ridge parser (current lookup is plain ISO9660 — Debian ISOs with Rock Ridge extensions may miss some paths). - Full ISO9660 + Joliet + Rock Ridge parser (current lookup is plain ISO9660 — Debian ISOs with Rock Ridge extensions may miss some paths).
- Real-hardware Windows boot validation (plumbing tested; no MS ISO pushed through the full pipeline yet). - Real-hardware Windows boot validation (plumbing tested; no MS ISO pushed through the full pipeline yet).
@@ -268,3 +331,14 @@ tab is one click from the brand bar.
- Pure-Rust SMB server (replace smbd) — slim image, no Samba. - Pure-Rust SMB server (replace smbd) — slim image, no Samba.
- Auto-install / autounattend file library (Bootimus v0.1.58 pattern). - Auto-install / autounattend file library (Bootimus v0.1.58 pattern).
- Per-client / per-group menus (Bootimus v0.1.16 pattern). - Per-client / per-group menus (Bootimus v0.1.16 pattern).
- Real-hardware integration: at minimum a Linux ISO booted on a real
BIOS box, a Windows ISO booted via wimboot on a real UEFI box, and a
Pi 4 booting from an NFS-mounted Raspberry Pi OS ISO.
- Distro profile manifest (Bootimus v0.1.27 pattern) — currently
introspection logic is hard-coded; could become data-driven so an
operator can add a new distro profile from the UI without rebuilding.
- Wake-on-LAN trigger (Bootimus v0.1.16 pattern) — power-on a host then
imaging starts unattended via a per-MAC binding.
- Syslog receiver (smee feature) — capture client-side install syslog
for diagnostic visibility.
- IPv6 PXE / DHCPv6 — currently IPv4 only.