From 49d0b00a8a0fc98d144fc5c5bc0e626a0a713b0e Mon Sep 17 00:00:00 2001 From: Miles Ward Date: Thu, 30 Apr 2026 02:28:10 -0400 Subject: [PATCH] =?UTF-8?q?v0.2.0=20=E2=80=94=20pre-beta:=20per-MAC=20bind?= =?UTF-8?q?ings,=20/metrics,=20themes,=20animated=20forge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /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 `
` 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. --- Cargo.toml | 2 +- README.md | 25 ++- crates/core/src/arch.rs | 3 +- crates/core/src/client.rs | 3 +- crates/core/src/config.rs | 14 +- crates/core/src/gate.rs | 2 +- crates/core/src/host_bindings.rs | 235 +++++++++++++++++++++++ crates/core/src/lib.rs | 4 + crates/core/src/metrics.rs | 283 +++++++++++++++++++++++++++ crates/dhcp-proxy/src/reply.rs | 9 +- crates/dhcp-proxy/src/server.rs | 14 +- crates/http-api/src/app.rs | 163 +++++++++++++++- crates/http-api/src/ipxe_script.rs | 8 +- crates/http-api/src/lib.rs | 4 +- crates/http-api/src/state.rs | 9 +- crates/http-api/src/terminal.rs | 8 +- crates/http-api/tests/full_flow.rs | 210 ++++++++++++++++++++- crates/ipxe-assets/src/lib.rs | 10 +- crates/iso-store/src/introspect.rs | 2 +- crates/iso-store/src/smb.rs | 28 ++- crates/iso-store/src/store.rs | 24 +-- crates/iso-store/src/windows.rs | 27 ++- crates/pxeforge/src/main.rs | 17 +- crates/tftp/src/server.rs | 41 ++-- crates/webui/src/anvil-forge.svg | 81 ++++++++ crates/webui/src/app.css | 294 +++++++++++++++++++---------- crates/webui/src/app.js | 166 +++++++++++++++- crates/webui/src/index.html | 41 +++- crates/webui/src/lib.rs | 15 +- crates/webui/src/logo.svg | 42 +++-- docs/architecture.md | 76 +++++++- 31 files changed, 1651 insertions(+), 209 deletions(-) create mode 100644 crates/core/src/host_bindings.rs create mode 100644 crates/core/src/metrics.rs create mode 100644 crates/webui/src/anvil-forge.svg diff --git a/Cargo.toml b/Cargo.toml index b74258e..4a97d29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.1.0" +version = "0.2.0" edition = "2021" rust-version = "1.80" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index fdc2d02..d801acc 100644 --- a/README.md +++ b/README.md @@ -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 clients PXE-boot them. -> **Status:** Phase 3 MVP. Container image builds and runs, gate flow -> validated end-to-end (two clients join queue → operator assigns in UI → -> both wake within 1 s with the correct boot script). Ready for real -> hardware validation. +> **Status:** v0.2.0 / pre-beta. Phases 1–5 complete: full PXE stack, +> Gated Deployment queue, NFS-share ISO sources, live tracing log + an +> operator terminal, per-MAC host bindings (Tinkerbell-style), +> Prometheus `/metrics`, light/dark theme toggle, animated anvil +> imaging-progress widget. **66 tests passing**, clippy clean. Ready +> for real-hardware validation. ## Design non-negotiables @@ -43,9 +45,18 @@ clients PXE-boot them. selects *Gated Deployment* gets a numbered position and waits. The operator picks an ISO in the web UI and fires it to every waiting client simultaneously. -7. **Web UI** (Netbox-style): sidebar nav (Dashboard / Clients / Gated - Deployment / Images / Settings / About), top tabs, dark theme, teal - accents. All assets served from the binary — no external requests. +7. **Web UI** (Netbox-style): sidebar nav (Dashboard / Network / Forge + Gate / Storage / Hosts / Terminal / About), light + dark themes + (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 600s), the timeout action (stay / Local HDD / Gated Deployment), and feature toggles like Windows ISO support. The iPXE scripts regenerate diff --git a/crates/core/src/arch.rs b/crates/core/src/arch.rs index 2779741..9170d87 100644 --- a/crates/core/src/arch.rs +++ b/crates/core/src/arch.rs @@ -49,9 +49,8 @@ impl ClientArch { // ARM32 UEFI: upstream boot.ipxe.org does not publish a prebuilt // snponly variant for this arch. We return None so the DHCP // proxy declines rather than advertising a file we can't serve. - Self::Arm32Uefi => return None, + Self::Arm32Uefi | Self::Unknown(_) => return None, Self::Arm64Uefi => "snponly-arm64.efi", - Self::Unknown(_) => return None, }) } diff --git a/crates/core/src/client.rs b/crates/core/src/client.rs index b9fcd55..5332e62 100644 --- a/crates/core/src/client.rs +++ b/crates/core/src/client.rs @@ -89,7 +89,8 @@ impl ClientRegistry { pub fn list(&self) -> Vec { let guard = self.inner.read(); let mut v: Vec<_> = guard.values().cloned().collect(); - v.sort_by(|a, b| b.last_seen.cmp(&a.last_seen)); + // Reverse-chronological by last-seen (most recent first). + v.sort_by_key(|c| std::cmp::Reverse(c.last_seen)); v } diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 7d9c420..62bb090 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use std::net::{IpAddr, Ipv4Addr}; use std::path::{Path, PathBuf}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct Config { pub server: ServerConfig, @@ -113,15 +113,9 @@ impl Default for Paths { } } -impl Default for Config { - fn default() -> Self { - Self { - server: ServerConfig::default(), - network: NetworkConfig::default(), - paths: Paths::default(), - } - } -} +// `Config` derives `Default` because each component supplies its own +// non-trivial defaults via `impl Default` blocks above; deriving keeps +// this in sync if a new section is added. impl Config { pub fn from_toml_file(path: &Path) -> crate::Result { diff --git a/crates/core/src/gate.rs b/crates/core/src/gate.rs index acfd13f..148065d 100644 --- a/crates/core/src/gate.rs +++ b/crates/core/src/gate.rs @@ -246,7 +246,7 @@ mod tests { q3.touch(&id) }); 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(); assert!(result.is_some()); assert_eq!(result.unwrap().assigned_target.as_deref(), Some("x")); diff --git a/crates/core/src/host_bindings.rs b/crates/core/src/host_bindings.rs new file mode 100644 index 0000000..d04d070 --- /dev/null +++ b/crates/core/src/host_bindings.rs @@ -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 `/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, +} + +/// 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, + inner: Arc>, +} + +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::>(&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 { + 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 { + 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 = 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"); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index fc80e82..05f1683 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -7,7 +7,9 @@ pub mod client; pub mod config; pub mod error; pub mod gate; +pub mod host_bindings; pub mod log_bus; +pub mod metrics; pub mod settings; 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 error::{Error, Result}; pub use gate::{Gate, GateQueue}; +pub use host_bindings::{normalize_mac, HostBinding, HostBindings}; pub use log_bus::{LogBus, LogBusLayer, LogLine}; +pub use metrics::{HttpRoute, Metrics}; pub use settings::{Settings, SettingsStore, TimeoutAction}; diff --git a/crates/core/src/metrics.rs b/crates/core/src/metrics.rs new file mode 100644 index 0000000..499fe23 --- /dev/null +++ b/crates/core/src/metrics.rs @@ -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, +} + +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")); + } +} diff --git a/crates/dhcp-proxy/src/reply.rs b/crates/dhcp-proxy/src/reply.rs index 42ef8a0..9898feb 100644 --- a/crates/dhcp-proxy/src/reply.rs +++ b/crates/dhcp-proxy/src/reply.rs @@ -41,7 +41,14 @@ pub struct ReplyContext<'a> { pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective { match ctx.class { 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 => { // UEFI HTTP boot: client wants an http:// URL in option 67 diff --git a/crates/dhcp-proxy/src/server.rs b/crates/dhcp-proxy/src/server.rs index c64f834..b3d23ac 100644 --- a/crates/dhcp-proxy/src/server.rs +++ b/crates/dhcp-proxy/src/server.rs @@ -19,6 +19,7 @@ pub struct DhcpProxyServer { our_ip: Ipv4Addr, public_base_url: String, clients: Arc, + metrics: pxeforge_core::Metrics, } impl DhcpProxyServer { @@ -29,8 +30,17 @@ impl DhcpProxyServer { our_ip: Ipv4Addr, public_base_url: String, clients: Arc, + metrics: pxeforge_core::Metrics, ) -> 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<()> { @@ -116,12 +126,14 @@ impl DhcpProxyServer { }; let directive = decide(&ctx); if matches!(directive, BootDirective::Ignore) { + self.metrics.record_dhcp_decline(); tracing::debug!( target: "pxeforge::dhcp", mac=%mac, arch=?arch, "ignoring — no bootfile for arch" ); return Ok(()); } + self.metrics.record_dhcp_reply(arch.as_str()); let Some(reply) = build_reply(&ctx, &directive) else { return Ok(()); }; let mut out = Vec::with_capacity(512); diff --git a/crates/http-api/src/app.rs b/crates/http-api/src/app.rs index f91ab23..00ceaba 100644 --- a/crates/http-api/src/app.rs +++ b/crates/http-api/src/app.rs @@ -45,6 +45,7 @@ pub fn build_router(state: AppState) -> Router { .route("/assets/app.js", get(ui_js)) .route("/assets/app.css", get(ui_css)) .route("/assets/logo.svg", get(ui_logo)) + .route("/assets/anvil-forge.svg", get(ui_anvil_forge)) // iPXE script endpoints. .route("/boot.ipxe", get(boot_top_menu)) .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)) // Phase 4: operator terminal commands (whitelisted). .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()) // 16 GiB upload cap — ISOs are big; chunks stream so this isn't memory use. .layer(DefaultBodyLimit::max(16 * 1024 * 1024 * 1024)) @@ -115,6 +124,13 @@ async fn ui_logo() -> 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 ────────────────────────────────────────────────────────── fn text_plain(body: String) -> Response { @@ -122,10 +138,51 @@ fn text_plain(body: String) -> Response { .into_response() } -async fn boot_top_menu(State(state): State) -> 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, + Query(p): Query, +) -> Response { + state.metrics.record_http(pxeforge_core::HttpRoute::BootScript); let isos = state.iso_store.list(); 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/.ipxe`. Both share the same + // `/boot/` 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, } async fn boot_sub( @@ -204,7 +261,7 @@ async fn iso_file( return (StatusCode::NOT_FOUND, "no such iso").into_response(); }; 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)) .await.ok().flatten(); let Some(loc) = loc else { @@ -362,18 +419,27 @@ async fn api_status(State(state): State) -> Json { let nfs = state.nfs.list(); let nfs_active = nfs.iter().filter(|m| m.mounted).count(); let isos = state.iso_store.list(); + let clients = state.clients.list(); let gates = state.gates.list(); // Phase 4: dashboard tracks "imaging" as gates with an assignment // already issued — they're the ones actively chaining a boot script. let imaging = gates.iter().filter(|g| g.assigned_target.is_some()).count(); let waiting = gates.len() - imaging; + // 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 uptime_secs = (now - state.started_at).whole_seconds().max(0); Json(json!({ "version": env!("CARGO_PKG_VERSION"), "public_base_url": state.public_base_url, "iso_count": isos.len(), - "client_count": state.clients.list().len(), + "client_count": clients.len(), "gate_count": gates.len(), "imaging_count": imaging, "waiting_count": waiting, @@ -382,6 +448,7 @@ async fn api_status(State(state): State) -> Json { "smb": smb, "nfs_count": nfs.len(), "nfs_active": nfs_active, + "host_bindings": state.hosts.len(), "uptime_secs": uptime_secs, "started_at": state.started_at, "nic_name": state.nic_name, @@ -650,6 +717,94 @@ async fn api_network_put( StatusCode::NO_CONTENT } +// ─── Per-MAC host bindings ──────────────────────────────────────────────── + +async fn api_hosts_list(State(state): State) -> Json { + 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, + Json(body): Json, +) -> 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, + AxumPath(mac): AxumPath, +) -> StatusCode { + if state.hosts.remove(&mac) { + StatusCode::NO_CONTENT + } else { + StatusCode::NOT_FOUND + } +} + +// ─── Prometheus metrics ─────────────────────────────────────────────────── + +async fn api_metrics(State(state): State) -> 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)] mod tests { use super::*; diff --git a/crates/http-api/src/ipxe_script.rs b/crates/http-api/src/ipxe_script.rs index 29286ca..0b2f49e 100644 --- a/crates/http-api/src/ipxe_script.rs +++ b/crates/http-api/src/ipxe_script.rs @@ -35,11 +35,11 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str let base = base_url.trim_end_matches('/'); let timeout_ms = settings.boot_menu_timeout_secs.saturating_mul(1000); let default_item = match settings.timeout_action { - TimeoutAction::LocalHdd => "local", TimeoutAction::GatedDeployment => "gate", // Stay -> iPXE's `--timeout 0` is "no timeout". Pick any default - // label; the client waits for keypress. - TimeoutAction::Stay => "local", + // label; the client waits for keypress. We use the same label as + // LocalHdd to keep the menu's pre-highlight stable. + TimeoutAction::LocalHdd | TimeoutAction::Stay => "local", }; 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). fn fmt_size_mib(bytes: u64) -> String { let mib = bytes / (1024 * 1024); - format!("{} MB", mib) + format!("{mib} MB") } /// Assign `--key N ` hotkeys 1..9, then nothing for positions >=9. diff --git a/crates/http-api/src/lib.rs b/crates/http-api/src/lib.rs index 063a28a..71fe561 100644 --- a/crates/http-api/src/lib.rs +++ b/crates/http-api/src/lib.rs @@ -6,8 +6,8 @@ //! - `/ipxe/` bundled iPXE binaries (for UEFI HTTP boot) //! - `/iso/.iso` raw ISO file (with Range support) //! - `/iso//` files inside the ISO (for wimboot WIM fetches -//! and Linux kernel/initrd, without having to -//! re-extract on every request) +//! and Linux kernel/initrd, without having to +//! re-extract on every request) //! //! The `/` handler uses a read-only ISO9660 shim (see `iso_fs`) //! that lseeks into the ISO on disk — so we never keep extracted copies. diff --git a/crates/http-api/src/state.rs b/crates/http-api/src/state.rs index a57726b..b038f9a 100644 --- a/crates/http-api/src/state.rs +++ b/crates/http-api/src/state.rs @@ -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 std::sync::Arc; use time::OffsetDateTime; @@ -9,6 +9,13 @@ pub struct AppState { pub clients: Arc, pub settings: Arc, pub gates: Arc, + /// 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 /// `smb_dir` at startup; `None` in pure-Linux-only deployments where /// Windows support is not wired in. Settings toggle drives start/stop. diff --git a/crates/http-api/src/terminal.rs b/crates/http-api/src/terminal.rs index 83bf041..fa7b7c6 100644 --- a/crates/http-api/src/terminal.rs +++ b/crates/http-api/src/terminal.rs @@ -161,8 +161,8 @@ fn clients_text(s: &AppState) -> String { let mut out = String::new(); let _ = writeln!( out, - "{:<19} {:<16} {:<8} {}", - "MAC", "IP", "EVENTS", "LAST SEEN" + "{:<19} {:<16} {:<8} LAST SEEN", + "MAC", "IP", "EVENTS" ); for c in clients { 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 { let mut out = String::new(); let _ = writeln!( out, - "{:<24} {:<6} {:<7} {:<6} {}", - "ID", "VER", "STATUS", "ISOS", "TARGET" + "{:<24} {:<6} {:<7} {:<6} TARGET", + "ID", "VER", "STATUS", "ISOS" ); for m in mounts { let status = if m.mounted { "ok" } else { "down" }; diff --git a/crates/http-api/tests/full_flow.rs b/crates/http-api/tests/full_flow.rs index dd67e53..cdead3d 100644 --- a/crates/http-api/tests/full_flow.rs +++ b/crates/http-api/tests/full_flow.rs @@ -12,7 +12,7 @@ use axum::body::Body; 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_iso_store::{IsoStore, NfsManager}; use tempfile::tempdir; @@ -92,11 +92,15 @@ async fn build_state() -> (AppState, tempfile::TempDir) { let nfs = NfsManager::new(dir.path(), iso_store.clone()); iso_store.set_nfs_root(nfs.mount_root()); let log_bus = LogBus::new(64); + let hosts = HostBindings::load_or_default(dir.path()); + let metrics = Metrics::new(); let state = AppState { iso_store, clients, gates, settings, + hosts, + metrics, smb: None, nfs, log_bus, @@ -236,8 +240,8 @@ async fn gated_deployment_full_flow() { assert!(assign_json.contains(r#""assigned":1"#), "assign response: {assign_json}"); // Now assign to gate 1 too so the background poll wakes. - let body = format!(r#"{{"target":"fake-alpine-linux","gate_ids":[]}}"#); - post_json(&app, "/api/gate/assign", &body).await; + let body = r#"{"target":"fake-alpine-linux","gate_ids":[]}"#; + post_json(&app, "/api/gate/assign", body).await; let (poll_status, poll_body) = poll_future.await.unwrap(); assert_eq!(poll_status, StatusCode::OK); @@ -325,6 +329,7 @@ async fn ui_assets_served_offline() { ("/assets/app.js", "application/javascript"), ("/assets/app.css", "text/css"), ("/assets/logo.svg", "image/svg+xml"), + ("/assets/anvil-forge.svg", "image/svg+xml"), ] { let res = app .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] async fn network_endpoint_exposes_dns_round_trip() { let (state, _dir) = build_state().await; diff --git a/crates/ipxe-assets/src/lib.rs b/crates/ipxe-assets/src/lib.rs index 67225ba..5bc6803 100644 --- a/crates/ipxe-assets/src/lib.rs +++ b/crates/ipxe-assets/src/lib.rs @@ -40,10 +40,18 @@ pub fn asset_bytes(name: &str) -> Option> { 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> { + IpxeAssets::get(name).map(|f| f.data) +} + /// Enumerate embedded asset filenames. Useful for startup logging so the /// operator can immediately tell which architectures will work. pub fn list_assets() -> Vec { - 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. diff --git a/crates/iso-store/src/introspect.rs b/crates/iso-store/src/introspect.rs index 531d406..cd6a715 100644 --- a/crates/iso-store/src/introspect.rs +++ b/crates/iso-store/src/introspect.rs @@ -98,7 +98,7 @@ pub fn introspect(path: &Path) -> IntrospectionReport { // that happens in the store after introspection. let (k, i) = guess_kernel_initrd(report.family); report.kernel_path = k.map(str::to_string); - report.initrd_paths = i.iter().map(|s| s.to_string()).collect(); + report.initrd_paths = i.iter().map(std::string::ToString::to_string).collect(); report } diff --git a/crates/iso-store/src/smb.rs b/crates/iso-store/src/smb.rs index 93290c9..15cb4b0 100644 --- a/crates/iso-store/src/smb.rs +++ b/crates/iso-store/src/smb.rs @@ -87,13 +87,17 @@ impl SmbManager { /// Write out `smb.conf` for the currently-discovered shares. Safe to /// call while smbd is running — smbd reloads on SIGHUP. pub fn write_conf(&self) -> std::io::Result> { + use std::fmt::Write as _; std::fs::create_dir_all(&self.smb_dir)?; let shares = self.discover_shares(); let mut conf = String::new(); conf.push_str(SMB_CONF_GLOBAL); for name in &shares { let path = self.smb_dir.join(name); - conf.push_str(&format!( + // Per-share block. `write!` to String never fails — the unwrap + // is provably unreachable, but expect() makes that explicit. + write!( + conf, "\n[{name}]\n\ path = {}\n\ comment = PXEForge Windows install media ({name})\n\ @@ -103,7 +107,8 @@ impl SmbManager { browseable = yes\n\ available = yes\n", path.display(), - )); + ) + .expect("writing to a String is infallible"); } let tmp = self.conf_path.with_extension("conf.tmp"); std::fs::write(&tmp, conf)?; @@ -114,7 +119,7 @@ impl SmbManager { /// Start smbd. No-op if already running. pub fn start(&self) -> SmbState { let mut g = self.child.lock(); - if g.as_ref().map_or(false, |c| c.id() > 0) { + if g.as_ref().is_some_and(|c| c.id() > 0) { return self.state.lock().clone(); } if !smbd_present() { @@ -173,7 +178,10 @@ impl SmbManager { } }; 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 // from `Child::id` above, the child is alive because we hold the // Mutex guard `g`) and a well-defined signal constant. Return @@ -211,7 +219,7 @@ fn smbd_present() -> bool { false } -const SMB_CONF_GLOBAL: &str = r#"[global] +const SMB_CONF_GLOBAL: &str = r"[global] workgroup = PXEFORGE server min protocol = SMB2 smb ports = 445 @@ -227,7 +235,7 @@ lock directory = /tmp state directory = /tmp cache directory = /tmp pid directory = /tmp -"#; +"; /// Extract a Windows ISO at `iso_path` into `smb_dir//`. Uses /// `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) .output()?; if out.status.success() { return Ok(target); } - return Err(std::io::Error::new( - std::io::ErrorKind::Other, - format!("bsdtar failed: {}", String::from_utf8_lossy(&out.stderr)), - )); + return Err(std::io::Error::other(format!( + "bsdtar failed: {}", + String::from_utf8_lossy(&out.stderr) + ))); } Err(std::io::Error::new( std::io::ErrorKind::NotFound, diff --git a/crates/iso-store/src/store.rs b/crates/iso-store/src/store.rs index 7cda264..f60e78f 100644 --- a/crates/iso-store/src/store.rs +++ b/crates/iso-store/src/store.rs @@ -19,9 +19,10 @@ use tokio::io::AsyncWriteExt; /// `Nfs` entries point at a file inside a remote share that the /// `NfsManager` is keeping mounted. We resolve the on-disk path lazily /// in [`IsoStore::iso_path_for`] using the `nfs_root` set at startup. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum IsoSource { + #[default] Local, Nfs { mount_id: String, @@ -30,12 +31,6 @@ pub enum IsoSource { }, } -impl Default for IsoSource { - fn default() -> Self { - Self::Local - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IsoMeta { /// Stable slug used in URLs (derived from the uploaded filename). @@ -156,7 +151,11 @@ impl IsoStore { while let Some(e) = entries.next_entry().await? { let p = e.path(); if p.extension().and_then(|s| s.to_str()) != Some("json") { continue; } - if !p.file_name().and_then(|s| s.to_str()).map_or(false, |n| n.ends_with(".meta.json")) { + if !p + .file_name() + .and_then(|s| s.to_str()) + .is_some_and(|n| n.ends_with(".meta.json")) + { continue; } if let Ok(text) = tokio::fs::read_to_string(&p).await { @@ -218,7 +217,8 @@ impl IsoStore { pub fn list(&self) -> Vec { let g = self.inner.read(); let mut v: Vec<_> = g.isos.values().cloned().collect(); - v.sort_by(|a, b| b.uploaded_at.cmp(&a.uploaded_at)); + // Newest-first by upload time. + v.sort_by_key(|m| std::cmp::Reverse(m.uploaded_at)); v } @@ -403,9 +403,9 @@ fn linux_cmdline(family: DistroFamily, id: &str) -> String { DistroFamily::OpenSuse => format!( "install={iso_url} netsetup=dhcp" ), - DistroFamily::Arch => format!( - "archiso_http_srv=${{base-url}}/iso/ archisobasedir=arch ip=dhcp copytoram" - ), + DistroFamily::Arch => { + "archiso_http_srv=${base-url}/iso/ archisobasedir=arch ip=dhcp copytoram".to_string() + } DistroFamily::Alpine => format!( "alpine_repo=${{base-url}}/iso/{id}/ modloop=${{base-url}}/iso/{id}/boot/modloop-lts ip=dhcp" ), diff --git a/crates/iso-store/src/windows.rs b/crates/iso-store/src/windows.rs index 4e153bc..836a7f0 100644 --- a/crates/iso-store/src/windows.rs +++ b/crates/iso-store/src/windows.rs @@ -152,18 +152,35 @@ const WINPESHL_INI: &str = "[LaunchApps]\r\n\ /// Uses CRLF line endings because WinPE cmd.exe requires them for .cmd files /// created on unix hosts. fn render_startnet(host: &str, share: &str) -> String { - let mut s = String::new(); + use std::fmt::Write as _; let host = host.trim(); 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 PXEForge WinPE bootstrap\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(&format!("echo Waiting for SMB server {host} to be reachable...\r\n")); - s.push_str(&format!(":waitsmb\r\nping -n 1 -w 500 {host} >nul && goto havenet\r\ntimeout /t 2 /nobreak >nul\r\ngoto waitsmb\r\n")); + writeln!(s, "echo Waiting for SMB server {host} to be reachable...\r").unwrap(); + 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(&format!("echo Mapping install media from \\\\{host}\\{share}...\r\n")); - s.push_str(&format!(":mapshare\r\nnet use Z: \\\\{host}\\{share} /user:guest \"\" /persistent:no && goto mapped\r\ntimeout /t 3 /nobreak >nul\r\ngoto mapshare\r\n")); + writeln!(s, "echo Mapping install media from \\\\{host}\\{share}...\r").unwrap(); + 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("echo Starting Windows Setup\r\n"); s.push_str("Z:\\setup.exe\r\n"); diff --git a/crates/pxeforge/src/main.rs b/crates/pxeforge/src/main.rs index 85518d2..02a7e38 100644 --- a/crates/pxeforge/src/main.rs +++ b/crates/pxeforge/src/main.rs @@ -3,7 +3,10 @@ //! them concurrently. 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_http_api::{build_router, AppState}; use pxeforge_iso_store::{IsoStore, NfsManager, SmbManager}; @@ -99,6 +102,8 @@ async fn main() -> anyhow::Result<()> { let clients = ClientRegistry::new(); let gates = GateQueue::new(); 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 // Windows toggle, not at process start. If the `smb_dir` isn't @@ -134,6 +139,8 @@ async fn main() -> anyhow::Result<()> { clients: clients.clone(), settings: settings.clone(), gates: gates.clone(), + hosts: hosts.clone(), + metrics: metrics.clone(), smb: Some(smb.clone()), nfs: nfs.clone(), log_bus: log_bus.clone(), @@ -153,7 +160,12 @@ async fn main() -> anyhow::Result<()> { 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 dhcp_task: tokio::task::JoinHandle> = match config.network.dhcp_mode { @@ -165,6 +177,7 @@ async fn main() -> anyhow::Result<()> { our_ip, public_base_url.clone(), clients.clone(), + metrics.clone(), ); tokio::spawn(s.run()) } diff --git a/crates/tftp/src/server.rs b/crates/tftp/src/server.rs index 9e17abf..e8acad5 100644 --- a/crates/tftp/src/server.rs +++ b/crates/tftp/src/server.rs @@ -35,17 +35,24 @@ pub struct TftpServer { bind: IpAddr, port: u16, clients: Arc, + metrics: pxeforge_core::Metrics, } impl TftpServer { - pub fn new(bind: IpAddr, port: u16, clients: Arc) -> Self { - Self { bind, port, clients } + pub fn new( + bind: IpAddr, + port: u16, + clients: Arc, + metrics: pxeforge_core::Metrics, + ) -> Self { + Self { bind, port, clients, metrics } } pub async fn run(self) -> anyhow::Result<()> { let sock = bind_udp(self.bind, self.port)?; tracing::info!(target: "pxeforge::tftp", "TFTP listening on {}:{}", self.bind, self.port); let clients = self.clients.clone(); + let metrics = self.metrics.clone(); let mut buf = vec![0u8; 2048]; loop { let (n, from) = match sock.recv_from(&mut buf).await { @@ -57,9 +64,11 @@ impl TftpServer { }; let data = buf[..n].to_vec(); let clients = clients.clone(); + let metrics = metrics.clone(); let bind_ip = self.bind; 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}"); } }); @@ -72,10 +81,10 @@ async fn handle_rrq( peer: SocketAddr, bind_ip: IpAddr, clients: Arc, + metrics: pxeforge_core::Metrics, ) -> anyhow::Result<()> { - let req = match parse_rrq(&packet) { - Some(r) => r, - None => return Ok(()), + let Some(req) = parse_rrq(&packet) else { + return Ok(()); }; let Request { filename, options, .. } = req; @@ -149,7 +158,11 @@ async fn handle_rrq( let total = file_bytes.len(); let mut offset: usize = 0; let mut block_no: u16 = 1; - let mut needs_zero_final = false; // spec: if last data block == blksize, follow with empty DATA + // 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 { let window_start_offset = offset; @@ -181,7 +194,7 @@ async fn handle_rrq( loop { match tokio::time::timeout(Duration::from_secs(3), recv_ack(&sock, peer)).await { Ok(Ok(acked)) if acked == last_block_in_window => break, - Ok(Ok(_)) => continue, // stale ACK from an earlier block — ignore + Ok(Ok(_)) => {} // stale ACK from an earlier block — ignore Ok(Err(e)) => return Err(e), Err(_) => { tries += 1; @@ -229,6 +242,7 @@ async fn handle_rrq( } tracing::debug!(target: "pxeforge::tftp", peer=%peer, bytes=total, "transfer complete"); + metrics.record_tftp_ok(total as u64); Ok(()) } @@ -249,7 +263,7 @@ fn parse_rrq(pkt: &[u8]) -> Option { let mode = read_cstr(&mut rest)?; let mut options = Vec::new(); while !rest.is_empty() { - let k = match read_cstr(&mut rest) { Some(s) => s, None => break }; + let Some(k) = read_cstr(&mut rest) else { break }; if k.is_empty() { break; } let v = read_cstr(&mut rest).unwrap_or_default(); options.push((k.to_ascii_lowercase(), v)); @@ -311,7 +325,7 @@ async fn recv_ack(sock: &UdpSocket, peer: SocketAddr) -> anyhow::Result { let code = u16::from_be_bytes([buf[2], buf[3]]); anyhow::bail!("client error {code}"); } - _ => continue, + _ => {} } } } @@ -327,7 +341,7 @@ async fn wait_for_ack( sock.send_to(to_retx, peer).await?; match tokio::time::timeout(Duration::from_secs(3), recv_ack(sock, peer)).await { Ok(Ok(b)) if b == expect_block => return Ok(true), - Ok(Ok(_)) => continue, + Ok(Ok(_)) => {} Ok(Err(_)) | Err(_) => { tries += 1; if tries > 5 { return Ok(false); } @@ -385,7 +399,10 @@ mod tests { fn parses_rrq_with_options() { // RRQ "snponly.efi" mode "octet" blksize=1468 tsize=0 let mut pkt = vec![0, OP_RRQ as u8]; - pkt.extend_from_slice(b"snponly.efi\0octet\0blksize\01468\0tsize\00\0"); + // 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(); assert_eq!(r.filename, "snponly.efi"); assert_eq!(r.mode, "octet"); diff --git a/crates/webui/src/anvil-forge.svg b/crates/webui/src/anvil-forge.svg new file mode 100644 index 0000000..a929ea4 --- /dev/null +++ b/crates/webui/src/anvil-forge.svg @@ -0,0 +1,81 @@ + + PXEForge — forging + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/webui/src/app.css b/crates/webui/src/app.css index 8f740d9..88154f0 100644 --- a/crates/webui/src/app.css +++ b/crates/webui/src/app.css @@ -1,35 +1,66 @@ -/* PXEForge web UI — Netbox-style layout, fully offline. - * Design tokens are CSS variables so a later phase can re-theme without - * touching markup or JS. */ +/* PXEForge web UI — Netbox-style minimal layout, fully offline. + * + * 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 { - --bg: #0b1018; - --bg-panel: #121826; - --bg-panel-2: #1a2334; - --bg-elev: #223047; - --fg: #e4e8ef; - --fg-dim: #8a94a7; - --fg-dimmer: #5a6379; - --accent: #00d4b4; /* Netbox-ish teal */ - --accent-dim: #07a38c; - --warn: #ffb347; - --err: #ef6e6e; - --ok: #4ade80; - --border: #223047; + /* Dark palette (default). */ + --bg: #0b1018; + --bg-panel: #121826; + --bg-panel-2: #1a2334; + --bg-elev: #223047; + --fg: #e4e8ef; + --fg-dim: #8a94a7; + --fg-dimmer: #5a6379; + --accent: #00d4b4; /* Netbox-ish teal */ + --accent-dim: #07a38c; + --warn: #ffb347; + --err: #ef6e6e; + --ok: #4ade80; + --border: #223047; --border-soft: #172033; - --radius: 6px; + --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; --sidebar-w: 240px; - --topbar-h: 54px; + --topbar-h: 56px; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --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; } html, body { height: 100%; } body { margin: 0; font-family: var(--sans); font-size: 14px; line-height: 1.5; background: var(--bg); color: var(--fg); + transition: background 0.16s ease, color 0.16s ease; } a { color: var(--accent); text-decoration: none; } 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 .sub { color: var(--fg-dim); font-size: 11px; } .sidebar nav { padding: 10px 0; flex: 1; overflow-y: auto; } -.sidebar nav .group { - padding: 10px 18px 6px; - font-size: 10.5px; color: var(--fg-dimmer); text-transform: uppercase; - letter-spacing: 1px; -} .sidebar nav a { display: flex; align-items: center; gap: 10px; - padding: 7px 18px; color: var(--fg); font-size: 13.5px; + padding: 8px 18px; color: var(--fg); font-size: 13.5px; border-left: 2px solid transparent; + cursor: pointer; } .sidebar nav a:hover { background: var(--bg-panel-2); text-decoration: none; } .sidebar nav a.active { @@ -96,7 +123,7 @@ code, kbd { font-family: var(--mono); font-size: 12.5px; .topbar { grid-area: topbar; display: flex; align-items: center; - padding: 0 20px; gap: 18px; + padding: 0 20px; gap: 14px; background: var(--bg-panel); 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; color: var(--fg); letter-spacing: 0.2px; } -.topbar .tabs { display: flex; gap: 4px; margin-left: 24px; } -.topbar .tabs button { - background: transparent; border: 0; - color: var(--fg-dim); font: inherit; - padding: 10px 14px; cursor: pointer; - border-bottom: 2px solid transparent; -} -.topbar .tabs button:hover { color: var(--fg); } -.topbar .tabs button.active { color: var(--accent); border-bottom-color: var(--accent); } .topbar .spacer { flex: 1; } .topbar .chip { background: var(--bg-panel-2); border: 1px solid var(--border); color: var(--fg-dim); font-size: 12px; padding: 4px 10px; border-radius: 12px; + white-space: nowrap; } .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 { @@ -142,6 +177,7 @@ code, kbd { font-family: var(--mono); font-size: 12.5px; border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; + box-shadow: var(--shadow-card); } .card > header { 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 .body { padding: 16px; } -.stat { - padding: 16px; -} +.stat { padding: 16px; } .stat .label { color: var(--fg-dim); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.8px; } .stat .value { font-size: 28px; font-weight: 600; line-height: 1.1; margin-top: 4px; color: var(--fg); } .stat .trend { font-size: 12px; color: var(--fg-dim); margin-top: 4px; } @@ -179,12 +213,12 @@ td.num { text-align: right; font-variant-numeric: tabular-nums; } display: inline-block; padding: 2px 8px; border-radius: 10px; 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.warn { background: #3a2a10; color: var(--warn); } -.tag.err { background: #3a1515; color: var(--err); } -.tag.accent { background: #072f29; color: var(--accent); } +.tag.ok { background: color-mix(in srgb, var(--ok) 22%, transparent); color: var(--ok); } +.tag.warn { background: color-mix(in srgb, var(--warn) 22%, transparent); color: var(--warn); } +.tag.err { background: color-mix(in srgb, var(--err) 22%, transparent); color: var(--err); } +.tag.accent { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--accent); } .tag.arch { text-transform: uppercase; } /* ── Forms ────────────────────────────────────────────────────────── */ @@ -194,12 +228,13 @@ button, .btn { border: 0; border-radius: var(--radius); padding: 7px 14px; font: inherit; font-weight: 600; cursor: pointer; + transition: background 0.12s ease; } button:hover, .btn:hover { background: var(--accent-dim); color: #fff; } button.ghost { background: transparent; color: var(--fg); border: 1px solid var(--border); } button.ghost:hover { background: var(--bg-panel-2); color: var(--fg); } -button.danger { background: transparent; color: var(--err); border: 1px solid #4a1f1f; } -button.danger:hover { background: #2a0b0b; color: var(--err); } +button.danger { background: transparent; color: var(--err); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); } +button.danger:hover { background: color-mix(in srgb, var(--err) 14%, transparent); color: var(--err); } label.field { display: grid; gap: 4px; margin-bottom: 14px; @@ -238,12 +273,114 @@ label.check input { accent-color: var(--accent); } background: var(--bg-panel-2); } .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.active { display: block; } .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 { display: grid; gap: 6px; padding: 10px 0; @@ -266,59 +403,16 @@ label.check input { accent-color: var(--accent); } .msg.err { color: var(--err); } .msg.ok { color: var(--ok); } -/* ── Top bar readiness chip ──────────────────────────────────────── */ -.chip.ready { background: #103428; color: var(--ok); border-color: #1a4f3c; } -.chip.notready { background: #3a1515; color: var(--err); border-color: #5a1f1f; } -.chip.warming { background: #3a2a10; color: var(--warn); border-color: #4a3a18; } - -/* ── Dashboard stat strip ────────────────────────────────────────── */ -.statstrip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; } -@media (max-width: 1100px) { .statstrip { grid-template-columns: repeat(2, 1fr); } } - -.kv { display: grid; grid-template-columns: 160px 1fr; gap: 6px 14px; - padding: 4px 0; font-size: 13px; } -.kv .k { color: var(--fg-dim); } -.kv .v { font-family: var(--mono); color: var(--fg); word-break: break-all; } -.kv .v.warn { color: var(--warn); } -.kv .v.err { color: var(--err); } -.kv .v.ok { color: var(--ok); } - -/* ── Image rows: amber tint on un-bootable images (Bootimus pattern) ── */ -tr.unbootable td { background: rgba(255, 179, 71, 0.07) !important; } -tr.unbootable td:first-child { border-left: 3px solid var(--warn); } -.row-warn { color: var(--warn); font-size: 11.5px; margin-top: 2px; } - -/* ── Table source badge ──────────────────────────────────────────── */ -.src-badge { font-family: var(--mono); font-size: 11px; padding: 1px 6px; - border-radius: 4px; background: var(--bg-elev); color: var(--fg-dim); } -.src-badge.nfs { background: #122a3a; color: #7cd3ff; } - -/* ── NFS modal-ish add form ──────────────────────────────────────── */ -.nfs-row { display: grid; grid-template-columns: 32px 1fr auto auto auto; align-items: center; - gap: 14px; padding: 10px 14px; background: var(--bg-panel-2); - border-left: 3px solid var(--accent); border-radius: var(--radius); } -.nfs-row.down { border-left-color: var(--err); } -.nfs-row .id { font-family: var(--mono); font-size: 12.5px; color: var(--fg); } -.nfs-row .meta { color: var(--fg-dim); font-size: 12px; } -.nfs-row .err { color: var(--err); font-size: 11.5px; word-break: break-all; } -.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } -.dot.ok { background: var(--ok); } -.dot.err { background: var(--err); } -.dot.warn { background: var(--warn); } - -/* ── Inline form rows (used by Network + NFS add) ───────────────── */ -.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; } -@media (max-width: 900px) { .form-row { grid-template-columns: 1fr; } } - /* ── Terminal pane ──────────────────────────────────────────────── */ .terminal { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: var(--radius-lg); - background: #06090e; + background: var(--terminal-bg); overflow: hidden; height: calc(100vh - var(--topbar-h) - 90px); min-height: 480px; + box-shadow: var(--shadow-card); } .terminal .pane { 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-warn { color: var(--warn); } .terminal .pane .lvl-info { color: #cfd6e2; } -.terminal .pane .lvl-debug { color: var(--fg-dim); } -.terminal .pane .lvl-trace { color: var(--fg-dimmer); } -.terminal .pane .ts { color: var(--fg-dimmer); } +.terminal .pane .lvl-debug { color: #8b94a8; } +.terminal .pane .lvl-trace { color: #5a6379; } +.terminal .pane .ts { color: #5a6379; } .terminal .pane .tg { color: #7cd3ff; } .terminal .pane .echo { color: var(--accent); } .terminal .input-row { display: flex; align-items: center; gap: 8px; padding: 8px 14px; background: #0a0e15; - border-top: 1px solid var(--border); + border-top: 1px solid #1d2330; } .terminal .input-row .prompt { color: var(--accent); font-family: var(--mono); } .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; outline: none; padding: 4px 0; } .terminal .toolbar { display: flex; gap: 8px; align-items: center; padding: 8px 14px; - background: var(--bg-panel-2); - border-bottom: 1px solid var(--border); - font-size: 12px; color: var(--fg-dim); + background: #0a0e15; + border-bottom: 1px solid #1d2330; + font-size: 12px; color: #8a94a7; } .terminal .toolbar .right { margin-left: auto; display: flex; gap: 6px; } .terminal .toolbar button { padding: 3px 9px; font-size: 11px; - background: transparent; color: var(--fg-dim); border: 1px solid var(--border); + background: transparent; color: #8a94a7; border: 1px solid #1d2330; 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-hero { padding: 20px 24px; } diff --git a/crates/webui/src/app.js b/crates/webui/src/app.js index 0fa6aa8..9477292 100644 --- a/crates/webui/src/app.js +++ b/crates/webui/src/app.js @@ -75,6 +75,26 @@ 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 // tint borrowed from Bootimus v0.1.62. Returns {ok, reason}. function bootability(iso, settings) { @@ -112,11 +132,14 @@ ipxeOk ? 'Bootloaders bundled, accepting clients' : 'No iPXE binaries bundled'), ])), - el('div', {class: 'card'}, el('div', {class: 'stat'}, [ - el('div', {class: 'label'}, 'Imaging now'), - el('div', {class: 'value'}, String(status.imaging_count || 0)), - el('div', {class: 'trend'}, (status.waiting_count || 0) + ' waiting at gate'), - ])), + el('div', {class: 'card'}, [ + el('div', {class: 'stat', style: 'padding-bottom:0'}, [ + el('div', {class: 'label'}, 'Imaging now'), + el('div', {class: 'value'}, String(status.imaging_count || 0)), + el('div', {class: 'trend'}, (status.waiting_count || 0) + ' waiting at gate'), + ]), + forgeProgressWidget(status.imaging_count || 0, status.gate_count || 0), + ]), el('div', {class: 'card'}, el('div', {class: 'stat'}, [ el('div', {class: 'label'}, 'Images available'), el('div', {class: 'value'}, String(isos.length)), @@ -268,7 +291,13 @@ : el('div', {class:'empty'}, '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'}, [ + el('div', {class:'card'}, [ + el('header', {}, el('h2', {}, 'Forge')), + forgeProgressWidget(imaging, gates.length), + ]), el('div', {class:'card'}, [ el('header', {}, el('h2', {}, 'Launch an image across the gate')), 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 () => { // Two-pane layout: live log on top (auto-scrolling), command line // on bottom. Mirrors the Minecraft-server console feel from the @@ -638,10 +760,35 @@ network: 'Network', gate: 'Forge Gate', storage: 'Storage', + hosts: 'Hosts', terminal: 'Terminal', about: 'About', }; + // Theme toggle. The data-attribute is set on 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; async function render(view) { @@ -654,7 +801,13 @@ try { currentBody._cleanup(); } catch (e) { /* ignore */ } } 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 { const body = await views[view](); root.innerHTML = ''; @@ -674,6 +827,7 @@ $$('[data-bind=iso_count],[data-bind=iso_count2]').forEach(n => n.textContent = String(s.iso_count)); $$('[data-bind=client_count],[data-bind=client_count2]').forEach(n => n.textContent = String(s.client_count)); $$('[data-bind=gate_count],[data-bind=gate_count2]').forEach(n => n.textContent = String(s.gate_count)); + $$('[data-bind=host_count]').forEach(n => n.textContent = String(s.host_bindings || 0)); const chip = $('[data-bind=ready_chip]'); if (chip) { if (r.ok) { chip.textContent = '● ready'; chip.className = 'chip ready'; } diff --git a/crates/webui/src/index.html b/crates/webui/src/index.html index e007239..a04b5bb 100644 --- a/crates/webui/src/index.html +++ b/crates/webui/src/index.html @@ -3,9 +3,24 @@ + PXEForge + +
@@ -14,7 +29,7 @@
PXEForge -
v0.1.0
+
v0.2.0
@@ -44,6 +63,26 @@ 0 images 0 clients 0 at gate +
diff --git a/crates/webui/src/lib.rs b/crates/webui/src/lib.rs index dad5b1a..c8928c3 100644 --- a/crates/webui/src/lib.rs +++ b/crates/webui/src/lib.rs @@ -23,7 +23,14 @@ pub fn app_css() -> &'static str { APP_CSS } #[must_use] pub fn logo_svg() -> &'static str { LOGO_SVG } -const INDEX_HTML: &str = include_str!("index.html"); -const APP_CSS: &str = include_str!("app.css"); -const APP_JS: &str = include_str!("app.js"); -const LOGO_SVG: &str = include_str!("logo.svg"); +/// Animated forging anvil — sparks rise + glow pulse. Used for the +/// imaging-progress widget and any "I'm working" loading state. Pure +/// SVG + SMIL, no JS, no GIF. +#[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"); diff --git a/crates/webui/src/logo.svg b/crates/webui/src/logo.svg index 1a973ad..20e949c 100644 --- a/crates/webui/src/logo.svg +++ b/crates/webui/src/logo.svg @@ -1,14 +1,32 @@ - + PXEForge - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/architecture.md b/docs/architecture.md index 891c514..580bf74 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 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 `/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). - 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. - Auto-install / autounattend file library (Bootimus v0.1.58 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.