Compare commits
2
Commits
c607f2e31c
..
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9c4f408a9 | ||
|
|
3517c67831 |
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(cargo check *)",
|
|
||||||
"Bash(cargo build *)",
|
|
||||||
"Bash(cargo clippy *)",
|
|
||||||
"Bash(cargo fmt *)",
|
|
||||||
"Bash(cargo tree *)",
|
|
||||||
"Bash(cargo doc *)",
|
|
||||||
"Bash(cargo test --workspace --lib)",
|
|
||||||
"Bash(cargo test --workspace)",
|
|
||||||
"Bash(cargo --version)",
|
|
||||||
"Bash(rustc --version)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -12,7 +12,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.80"
|
rust-version = "1.80"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ Container-native PXE boot server. A Rust reimplementation of
|
|||||||
for Docker/OCI and OpenShift. Upload `.iso` files via the web UI; network
|
for Docker/OCI and OpenShift. Upload `.iso` files via the web UI; network
|
||||||
clients PXE-boot them.
|
clients PXE-boot them.
|
||||||
|
|
||||||
> **Status:** v0.2.0 / pre-beta. Phases 1–5 complete: full PXE stack,
|
> **Status:** Phase 3 MVP. Container image builds and runs, gate flow
|
||||||
> Gated Deployment queue, NFS-share ISO sources, live tracing log + an
|
> validated end-to-end (two clients join queue → operator assigns in UI →
|
||||||
> operator terminal, per-MAC host bindings (Tinkerbell-style),
|
> both wake within 1 s with the correct boot script). Ready for real
|
||||||
> Prometheus `/metrics`, light/dark theme toggle, animated anvil
|
> hardware validation.
|
||||||
> imaging-progress widget. **66 tests passing**, clippy clean. Ready
|
|
||||||
> for real-hardware validation.
|
|
||||||
|
|
||||||
## Design non-negotiables
|
## Design non-negotiables
|
||||||
|
|
||||||
@@ -45,18 +43,9 @@ clients PXE-boot them.
|
|||||||
selects *Gated Deployment* gets a numbered position and waits. The
|
selects *Gated Deployment* gets a numbered position and waits. The
|
||||||
operator picks an ISO in the web UI and fires it to every waiting
|
operator picks an ISO in the web UI and fires it to every waiting
|
||||||
client simultaneously.
|
client simultaneously.
|
||||||
7. **Web UI** (Netbox-style): sidebar nav (Dashboard / Network / Forge
|
7. **Web UI** (Netbox-style): sidebar nav (Dashboard / Clients / Gated
|
||||||
Gate / Storage / Hosts / Terminal / About), light + dark themes
|
Deployment / Images / Settings / About), top tabs, dark theme, teal
|
||||||
(toggle top-right or press `T`), animated anvil "forge progress"
|
accents. All assets served from the binary — no external requests.
|
||||||
widget when devices are imaging. All assets served from the binary —
|
|
||||||
no external requests.
|
|
||||||
8. **Per-MAC host bindings.** Pin a MAC to a boot target and the client
|
|
||||||
skips the menu, chains straight through. Inspired by Tinkerbell's
|
|
||||||
`smee` MAC-prepended URL pattern.
|
|
||||||
9. **Prometheus metrics** at `/metrics` — DHCP replies by arch, TFTP
|
|
||||||
transfer counts and bytes, HTTP request counts by route, gate /
|
|
||||||
imaging gauges, uptime, build info. Plain text exposition format,
|
|
||||||
no external metrics framework dependency.
|
|
||||||
8. **Settings API** lets you change the default boot-menu timeout (default
|
8. **Settings API** lets you change the default boot-menu timeout (default
|
||||||
600s), the timeout action (stay / Local HDD / Gated Deployment), and
|
600s), the timeout action (stay / Local HDD / Gated Deployment), and
|
||||||
feature toggles like Windows ISO support. The iPXE scripts regenerate
|
feature toggles like Windows ISO support. The iPXE scripts regenerate
|
||||||
|
|||||||
@@ -49,8 +49,9 @@ impl ClientArch {
|
|||||||
// ARM32 UEFI: upstream boot.ipxe.org does not publish a prebuilt
|
// ARM32 UEFI: upstream boot.ipxe.org does not publish a prebuilt
|
||||||
// snponly variant for this arch. We return None so the DHCP
|
// snponly variant for this arch. We return None so the DHCP
|
||||||
// proxy declines rather than advertising a file we can't serve.
|
// proxy declines rather than advertising a file we can't serve.
|
||||||
Self::Arm32Uefi | Self::Unknown(_) => return None,
|
Self::Arm32Uefi => return None,
|
||||||
Self::Arm64Uefi => "snponly-arm64.efi",
|
Self::Arm64Uefi => "snponly-arm64.efi",
|
||||||
|
Self::Unknown(_) => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,8 +89,7 @@ impl ClientRegistry {
|
|||||||
pub fn list(&self) -> Vec<ClientSnapshot> {
|
pub fn list(&self) -> Vec<ClientSnapshot> {
|
||||||
let guard = self.inner.read();
|
let guard = self.inner.read();
|
||||||
let mut v: Vec<_> = guard.values().cloned().collect();
|
let mut v: Vec<_> = guard.values().cloned().collect();
|
||||||
// Reverse-chronological by last-seen (most recent first).
|
v.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
|
||||||
v.sort_by_key(|c| std::cmp::Reverse(c.last_seen));
|
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::net::{IpAddr, Ipv4Addr};
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub server: ServerConfig,
|
pub server: ServerConfig,
|
||||||
@@ -113,9 +113,15 @@ impl Default for Paths {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// `Config` derives `Default` because each component supplies its own
|
impl Default for Config {
|
||||||
// non-trivial defaults via `impl Default` blocks above; deriving keeps
|
fn default() -> Self {
|
||||||
// this in sync if a new section is added.
|
Self {
|
||||||
|
server: ServerConfig::default(),
|
||||||
|
network: NetworkConfig::default(),
|
||||||
|
paths: Paths::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn from_toml_file(path: &Path) -> crate::Result<Self> {
|
pub fn from_toml_file(path: &Path) -> crate::Result<Self> {
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ mod tests {
|
|||||||
q3.touch(&id)
|
q3.touch(&id)
|
||||||
});
|
});
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||||
q2.assign(std::slice::from_ref(&g.id), "x");
|
q2.assign(&[g.id.clone()], "x");
|
||||||
let result = fut.await.unwrap();
|
let result = fut.await.unwrap();
|
||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
assert_eq!(result.unwrap().assigned_target.as_deref(), Some("x"));
|
assert_eq!(result.unwrap().assigned_target.as_deref(), Some("x"));
|
||||||
|
|||||||
@@ -1,235 +0,0 @@
|
|||||||
//! Per-MAC host bindings.
|
|
||||||
//!
|
|
||||||
//! Inspired by the Tinkerbell `smee` "MAC-prepended URL" pattern: an
|
|
||||||
//! operator can attach a preferred boot target (a `BootEntry::id`) to a
|
|
||||||
//! specific MAC address. When a client with that MAC arrives, the
|
|
||||||
//! top-level boot script chains straight to that target instead of
|
|
||||||
//! showing the interactive menu.
|
|
||||||
//!
|
|
||||||
//! Use cases:
|
|
||||||
//! - "This rack of Dell servers always images with Ubuntu Server 24.04"
|
|
||||||
//! - "Tom's laptop always boots from local disk"
|
|
||||||
//! - "Bench QA machines always boot Memtest until released"
|
|
||||||
//!
|
|
||||||
//! Persisted to `<work_dir>/hosts.json`. Like the SettingsStore, on-disk
|
|
||||||
//! corruption falls back to an empty registry rather than failing
|
|
||||||
//! startup — a bad hosts file should never block PXE for the network.
|
|
||||||
|
|
||||||
use parking_lot::RwLock;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use time::OffsetDateTime;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct HostBinding {
|
|
||||||
/// Lowercase, colon-separated MAC (e.g. `aa:bb:cc:dd:ee:ff`). The
|
|
||||||
/// HTTP layer normalizes incoming MACs before lookup so callers
|
|
||||||
/// don't have to worry about case.
|
|
||||||
pub mac: String,
|
|
||||||
/// Preferred boot entry id (matches a `BootEntry::id` in the iso
|
|
||||||
/// store) OR one of the reserved menu names: `_local`, `_gate`,
|
|
||||||
/// `_tools_menu`. Empty string falls back to the menu.
|
|
||||||
pub target: String,
|
|
||||||
/// Optional human-readable label shown in the UI (`"Tom's laptop"`,
|
|
||||||
/// `"rack-3 spine"`). Empty if unset.
|
|
||||||
#[serde(default)]
|
|
||||||
pub label: String,
|
|
||||||
#[serde(with = "time::serde::rfc3339")]
|
|
||||||
pub created_at: OffsetDateTime,
|
|
||||||
#[serde(with = "time::serde::rfc3339")]
|
|
||||||
pub updated_at: OffsetDateTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct Inner {
|
|
||||||
by_mac: HashMap<String, HostBinding>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Registry of per-MAC bindings. Cheap to clone; locks are held
|
|
||||||
/// briefly. Persistence is best-effort and mirrors `SettingsStore`'s
|
|
||||||
/// "in-memory authoritative, disk is a cache" policy.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct HostBindings {
|
|
||||||
path: Arc<PathBuf>,
|
|
||||||
inner: Arc<RwLock<Inner>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HostBindings {
|
|
||||||
/// Load from `work_dir/hosts.json`, or start empty if absent /
|
|
||||||
/// unreadable.
|
|
||||||
#[must_use]
|
|
||||||
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
|
|
||||||
let path = work_dir.join("hosts.json");
|
|
||||||
let inner = match std::fs::read_to_string(&path) {
|
|
||||||
Ok(text) => match serde_json::from_str::<Vec<HostBinding>>(&text) {
|
|
||||||
Ok(items) => {
|
|
||||||
let mut by_mac = HashMap::new();
|
|
||||||
for b in items {
|
|
||||||
by_mac.insert(normalize_mac(&b.mac), b);
|
|
||||||
}
|
|
||||||
Inner { by_mac }
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
target: "pxeforge::hosts",
|
|
||||||
"hosts.json present but unreadable ({e}); starting empty"
|
|
||||||
);
|
|
||||||
Inner::default()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(_) => Inner::default(),
|
|
||||||
};
|
|
||||||
Self {
|
|
||||||
path: Arc::new(path),
|
|
||||||
inner: Arc::new(RwLock::new(inner)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Look up a binding by MAC. Match is case-insensitive and tolerates
|
|
||||||
/// `-` or `:` separators.
|
|
||||||
#[must_use]
|
|
||||||
pub fn lookup(&self, mac: &str) -> Option<HostBinding> {
|
|
||||||
self.inner.read().by_mac.get(&normalize_mac(mac)).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert or update. Returns the resulting binding (with timestamps).
|
|
||||||
pub fn upsert(&self, mac: &str, target: &str, label: &str) -> HostBinding {
|
|
||||||
let key = normalize_mac(mac);
|
|
||||||
let now = OffsetDateTime::now_utc();
|
|
||||||
let binding = {
|
|
||||||
let mut g = self.inner.write();
|
|
||||||
let entry = g.by_mac.entry(key.clone()).or_insert_with(|| HostBinding {
|
|
||||||
mac: key.clone(),
|
|
||||||
target: target.to_string(),
|
|
||||||
label: label.to_string(),
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
});
|
|
||||||
entry.target = target.to_string();
|
|
||||||
entry.label = label.to_string();
|
|
||||||
entry.updated_at = now;
|
|
||||||
entry.clone()
|
|
||||||
};
|
|
||||||
self.persist();
|
|
||||||
binding
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a binding. Returns true if something was removed.
|
|
||||||
pub fn remove(&self, mac: &str) -> bool {
|
|
||||||
let key = normalize_mac(mac);
|
|
||||||
let removed = self.inner.write().by_mac.remove(&key).is_some();
|
|
||||||
if removed {
|
|
||||||
self.persist();
|
|
||||||
}
|
|
||||||
removed
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn list(&self) -> Vec<HostBinding> {
|
|
||||||
let g = self.inner.read();
|
|
||||||
let mut v: Vec<_> = g.by_mac.values().cloned().collect();
|
|
||||||
v.sort_by(|a, b| a.mac.cmp(&b.mac));
|
|
||||||
v
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn len(&self) -> usize {
|
|
||||||
self.inner.read().by_mac.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.len() == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
fn persist(&self) {
|
|
||||||
let items: Vec<HostBinding> = self.inner.read().by_mac.values().cloned().collect();
|
|
||||||
let body = match serde_json::to_vec_pretty(&items) {
|
|
||||||
Ok(b) => b,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(target: "pxeforge::hosts", "serialize hosts.json: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let tmp = self.path.with_extension("json.tmp");
|
|
||||||
if let Some(parent) = self.path.parent() {
|
|
||||||
let _ = std::fs::create_dir_all(parent);
|
|
||||||
}
|
|
||||||
if let Err(e) = std::fs::write(&tmp, body) {
|
|
||||||
tracing::warn!(target: "pxeforge::hosts", "write hosts.json tmp: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
|
|
||||||
tracing::warn!(target: "pxeforge::hosts", "rename hosts.json: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lowercase a MAC and normalise `-` separators to `:`. We never strip
|
|
||||||
/// the separator entirely — `aabbccddeeff` formats are rejected at the
|
|
||||||
/// HTTP layer because they're ambiguous (could be a device id).
|
|
||||||
#[must_use]
|
|
||||||
pub fn normalize_mac(mac: &str) -> String {
|
|
||||||
mac.trim().to_ascii_lowercase().replace('-', ":")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use tempfile::tempdir;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_handles_case_and_dashes() {
|
|
||||||
assert_eq!(normalize_mac("AA:BB:CC:DD:EE:FF"), "aa:bb:cc:dd:ee:ff");
|
|
||||||
assert_eq!(normalize_mac("aa-bb-cc-dd-ee-ff"), "aa:bb:cc:dd:ee:ff");
|
|
||||||
assert_eq!(normalize_mac(" aA-Bb-CC:DD-ee:fF "), "aa:bb:cc:dd:ee:ff");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn upsert_then_lookup() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let h = HostBindings::load_or_default(dir.path());
|
|
||||||
assert!(h.is_empty());
|
|
||||||
h.upsert("AA:BB:CC:00:00:01", "ubuntu-24-04-linux", "rack-3 spine");
|
|
||||||
let found = h.lookup("aa-bb-cc-00-00-01").expect("lookup");
|
|
||||||
assert_eq!(found.target, "ubuntu-24-04-linux");
|
|
||||||
assert_eq!(found.label, "rack-3 spine");
|
|
||||||
assert_eq!(found.mac, "aa:bb:cc:00:00:01");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn upsert_replaces_existing_target() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let h = HostBindings::load_or_default(dir.path());
|
|
||||||
h.upsert("aa:bb:cc:00:00:01", "old-target", "label1");
|
|
||||||
h.upsert("aa:bb:cc:00:00:01", "new-target", "label2");
|
|
||||||
assert_eq!(h.len(), 1);
|
|
||||||
let b = h.lookup("aa:bb:cc:00:00:01").unwrap();
|
|
||||||
assert_eq!(b.target, "new-target");
|
|
||||||
assert_eq!(b.label, "label2");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_works_and_reports_outcome() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let h = HostBindings::load_or_default(dir.path());
|
|
||||||
h.upsert("aa:bb:cc:00:00:01", "x", "");
|
|
||||||
assert!(h.remove("AA:BB:CC:00:00:01"));
|
|
||||||
assert!(!h.remove("aa:bb:cc:00:00:01")); // already gone
|
|
||||||
assert!(h.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trip_persists_to_disk() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let h = HostBindings::load_or_default(dir.path());
|
|
||||||
h.upsert("aa:bb:cc:00:00:01", "ubuntu-linux", "rack-3");
|
|
||||||
h.upsert("aa:bb:cc:00:00:02", "_local", "tom-laptop");
|
|
||||||
drop(h);
|
|
||||||
let h2 = HostBindings::load_or_default(dir.path());
|
|
||||||
assert_eq!(h2.len(), 2);
|
|
||||||
assert_eq!(h2.lookup("aa:bb:cc:00:00:02").unwrap().target, "_local");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,9 +7,7 @@ pub mod client;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod gate;
|
pub mod gate;
|
||||||
pub mod host_bindings;
|
|
||||||
pub mod log_bus;
|
pub mod log_bus;
|
||||||
pub mod metrics;
|
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|
||||||
pub use arch::{ClientArch, FirmwareClass};
|
pub use arch::{ClientArch, FirmwareClass};
|
||||||
@@ -17,7 +15,5 @@ pub use client::{ClientEvent, ClientRegistry, ClientSnapshot};
|
|||||||
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
|
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
|
||||||
pub use error::{Error, Result};
|
pub use error::{Error, Result};
|
||||||
pub use gate::{Gate, GateQueue};
|
pub use gate::{Gate, GateQueue};
|
||||||
pub use host_bindings::{normalize_mac, HostBinding, HostBindings};
|
|
||||||
pub use log_bus::{LogBus, LogBusLayer, LogLine};
|
pub use log_bus::{LogBus, LogBusLayer, LogLine};
|
||||||
pub use metrics::{HttpRoute, Metrics};
|
|
||||||
pub use settings::{Settings, SettingsStore, TimeoutAction};
|
pub use settings::{Settings, SettingsStore, TimeoutAction};
|
||||||
|
|||||||
@@ -1,283 +0,0 @@
|
|||||||
//! Tiny lock-free Prometheus-compatible metrics.
|
|
||||||
//!
|
|
||||||
//! We don't pull in `prometheus` or `metrics-rs` for this — they bring
|
|
||||||
//! their own runtime, registry, and complexity. PXEForge has a fixed,
|
|
||||||
//! tiny set of counters/gauges and the exposition format is plain text.
|
|
||||||
//! A handful of `AtomicU64`s and a `Display` impl gets us everything
|
|
||||||
//! Prometheus / Grafana / VictoriaMetrics needs to scrape:
|
|
||||||
//!
|
|
||||||
//! pxeforge_dhcp_replies_total counter (per arch label)
|
|
||||||
//! pxeforge_tftp_transfers_total counter (per status label)
|
|
||||||
//! pxeforge_tftp_bytes_total counter
|
|
||||||
//! pxeforge_http_requests_total counter (per route label)
|
|
||||||
//! pxeforge_iso_count gauge
|
|
||||||
//! pxeforge_client_count gauge
|
|
||||||
//! pxeforge_gate_count gauge
|
|
||||||
//! pxeforge_gate_imaging gauge
|
|
||||||
//! pxeforge_uptime_seconds gauge
|
|
||||||
//! pxeforge_build_info{version} gauge (always 1)
|
|
||||||
//!
|
|
||||||
//! Cheap to clone — internal state is a couple of arcs. Counters use
|
|
||||||
//! `Relaxed` ordering: we don't synchronise across counters, just need
|
|
||||||
//! per-counter monotonicity.
|
|
||||||
|
|
||||||
use std::fmt::Write as _;
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
#[allow(clippy::struct_field_names)]
|
|
||||||
struct Inner {
|
|
||||||
// DHCP proxy
|
|
||||||
dhcp_replies_legacy: AtomicU64,
|
|
||||||
dhcp_replies_uefi: AtomicU64,
|
|
||||||
dhcp_replies_arm64: AtomicU64,
|
|
||||||
dhcp_replies_unknown: AtomicU64,
|
|
||||||
dhcp_declined: AtomicU64,
|
|
||||||
// TFTP
|
|
||||||
tftp_transfers_ok: AtomicU64,
|
|
||||||
tftp_transfers_err: AtomicU64,
|
|
||||||
tftp_bytes: AtomicU64,
|
|
||||||
// HTTP
|
|
||||||
http_boot_script: AtomicU64,
|
|
||||||
http_iso_range: AtomicU64,
|
|
||||||
http_iso_inner: AtomicU64,
|
|
||||||
http_ipxe_binary: AtomicU64,
|
|
||||||
http_api: AtomicU64,
|
|
||||||
// Gauges (set explicitly; not cumulative)
|
|
||||||
iso_count: AtomicU64,
|
|
||||||
client_count: AtomicU64,
|
|
||||||
gate_count: AtomicU64,
|
|
||||||
gate_imaging: AtomicU64,
|
|
||||||
nfs_mounts_active: AtomicU64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct Metrics {
|
|
||||||
inner: Arc<Inner>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Metrics {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DHCP ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
pub fn record_dhcp_reply(&self, arch: &str) {
|
|
||||||
let counter = match arch {
|
|
||||||
"bios" => &self.inner.dhcp_replies_legacy,
|
|
||||||
"uefi-x64" | "uefi-ia32" => &self.inner.dhcp_replies_uefi,
|
|
||||||
"uefi-arm64" => &self.inner.dhcp_replies_arm64,
|
|
||||||
_ => &self.inner.dhcp_replies_unknown,
|
|
||||||
};
|
|
||||||
counter.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn record_dhcp_decline(&self) {
|
|
||||||
self.inner.dhcp_declined.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── TFTP ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
pub fn record_tftp_ok(&self, bytes: u64) {
|
|
||||||
self.inner.tftp_transfers_ok.fetch_add(1, Ordering::Relaxed);
|
|
||||||
self.inner.tftp_bytes.fetch_add(bytes, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn record_tftp_err(&self) {
|
|
||||||
self.inner.tftp_transfers_err.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── HTTP ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
pub fn record_http(&self, route: HttpRoute) {
|
|
||||||
let counter = match route {
|
|
||||||
HttpRoute::BootScript => &self.inner.http_boot_script,
|
|
||||||
HttpRoute::IsoRange => &self.inner.http_iso_range,
|
|
||||||
HttpRoute::IsoInner => &self.inner.http_iso_inner,
|
|
||||||
HttpRoute::IpxeBinary => &self.inner.http_ipxe_binary,
|
|
||||||
HttpRoute::Api => &self.inner.http_api,
|
|
||||||
};
|
|
||||||
counter.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Gauges ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
pub fn set_iso_count(&self, n: u64) {
|
|
||||||
self.inner.iso_count.store(n, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_client_count(&self, n: u64) {
|
|
||||||
self.inner.client_count.store(n, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_gate_counts(&self, total: u64, imaging: u64) {
|
|
||||||
self.inner.gate_count.store(total, Ordering::Relaxed);
|
|
||||||
self.inner.gate_imaging.store(imaging, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_nfs_active(&self, n: u64) {
|
|
||||||
self.inner.nfs_mounts_active.store(n, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render in the Prometheus text exposition format.
|
|
||||||
/// Uptime is supplied by the caller because `Metrics` doesn't own
|
|
||||||
/// the start instant; the HTTP layer does.
|
|
||||||
#[must_use]
|
|
||||||
pub fn render(&self, version: &str, uptime_secs: u64) -> String {
|
|
||||||
let mut out = String::with_capacity(2048);
|
|
||||||
let i = &self.inner;
|
|
||||||
|
|
||||||
// Helper closures.
|
|
||||||
let write_counter = |o: &mut String, name: &str, help: &str, val: u64, lbl: &str| {
|
|
||||||
let _ = writeln!(o, "# HELP {name} {help}");
|
|
||||||
let _ = writeln!(o, "# TYPE {name} counter");
|
|
||||||
if lbl.is_empty() {
|
|
||||||
let _ = writeln!(o, "{name} {val}");
|
|
||||||
} else {
|
|
||||||
let _ = writeln!(o, "{name}{{{lbl}}} {val}");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let write_gauge = |o: &mut String, name: &str, help: &str, val: u64, lbl: &str| {
|
|
||||||
let _ = writeln!(o, "# HELP {name} {help}");
|
|
||||||
let _ = writeln!(o, "# TYPE {name} gauge");
|
|
||||||
if lbl.is_empty() {
|
|
||||||
let _ = writeln!(o, "{name} {val}");
|
|
||||||
} else {
|
|
||||||
let _ = writeln!(o, "{name}{{{lbl}}} {val}");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Counters with one HELP/TYPE per metric name and per-label rows.
|
|
||||||
let _ = writeln!(out, "# HELP pxeforge_dhcp_replies_total Number of proxyDHCP replies sent, by client architecture.");
|
|
||||||
let _ = writeln!(out, "# TYPE pxeforge_dhcp_replies_total counter");
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"pxeforge_dhcp_replies_total{{arch=\"bios\"}} {}",
|
|
||||||
i.dhcp_replies_legacy.load(Ordering::Relaxed)
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"pxeforge_dhcp_replies_total{{arch=\"uefi\"}} {}",
|
|
||||||
i.dhcp_replies_uefi.load(Ordering::Relaxed)
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"pxeforge_dhcp_replies_total{{arch=\"arm64\"}} {}",
|
|
||||||
i.dhcp_replies_arm64.load(Ordering::Relaxed)
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"pxeforge_dhcp_replies_total{{arch=\"unknown\"}} {}",
|
|
||||||
i.dhcp_replies_unknown.load(Ordering::Relaxed)
|
|
||||||
);
|
|
||||||
write_counter(
|
|
||||||
&mut out,
|
|
||||||
"pxeforge_dhcp_declined_total",
|
|
||||||
"DHCP requests we saw but did not reply to (mac filter, arch unsupported, etc).",
|
|
||||||
i.dhcp_declined.load(Ordering::Relaxed),
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
|
|
||||||
let _ = writeln!(out, "# HELP pxeforge_tftp_transfers_total TFTP transfers, by status.");
|
|
||||||
let _ = writeln!(out, "# TYPE pxeforge_tftp_transfers_total counter");
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"pxeforge_tftp_transfers_total{{status=\"ok\"}} {}",
|
|
||||||
i.tftp_transfers_ok.load(Ordering::Relaxed)
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"pxeforge_tftp_transfers_total{{status=\"err\"}} {}",
|
|
||||||
i.tftp_transfers_err.load(Ordering::Relaxed)
|
|
||||||
);
|
|
||||||
write_counter(
|
|
||||||
&mut out,
|
|
||||||
"pxeforge_tftp_bytes_total",
|
|
||||||
"Total bytes successfully delivered over TFTP.",
|
|
||||||
i.tftp_bytes.load(Ordering::Relaxed),
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
|
|
||||||
let _ = writeln!(out, "# HELP pxeforge_http_requests_total HTTP requests served, by route family.");
|
|
||||||
let _ = writeln!(out, "# TYPE pxeforge_http_requests_total counter");
|
|
||||||
for (label, counter) in [
|
|
||||||
("boot_script", &i.http_boot_script),
|
|
||||||
("iso_range", &i.http_iso_range),
|
|
||||||
("iso_inner", &i.http_iso_inner),
|
|
||||||
("ipxe_binary", &i.http_ipxe_binary),
|
|
||||||
("api", &i.http_api),
|
|
||||||
] {
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"pxeforge_http_requests_total{{route=\"{label}\"}} {}",
|
|
||||||
counter.load(Ordering::Relaxed)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gauges.
|
|
||||||
write_gauge(&mut out, "pxeforge_iso_count", "ISOs currently registered (local + NFS).", i.iso_count.load(Ordering::Relaxed), "");
|
|
||||||
write_gauge(&mut out, "pxeforge_client_count", "PXE clients seen this process lifetime.", i.client_count.load(Ordering::Relaxed), "");
|
|
||||||
write_gauge(&mut out, "pxeforge_gate_count", "Clients currently waiting at the deployment gate.", i.gate_count.load(Ordering::Relaxed), "");
|
|
||||||
write_gauge(&mut out, "pxeforge_gate_imaging", "Clients currently imaging (gate + assigned target).", i.gate_imaging.load(Ordering::Relaxed), "");
|
|
||||||
write_gauge(&mut out, "pxeforge_nfs_mounts_active", "NFS shares currently mounted.", i.nfs_mounts_active.load(Ordering::Relaxed), "");
|
|
||||||
write_gauge(&mut out, "pxeforge_uptime_seconds", "Seconds since this PXEForge instance started.", uptime_secs, "");
|
|
||||||
|
|
||||||
let _ = writeln!(out, "# HELP pxeforge_build_info Build metadata. Always 1; the version is in the label.");
|
|
||||||
let _ = writeln!(out, "# TYPE pxeforge_build_info gauge");
|
|
||||||
let _ = writeln!(out, "pxeforge_build_info{{version=\"{version}\"}} 1");
|
|
||||||
|
|
||||||
out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stable label values for the HTTP route counter. Adding a new route
|
|
||||||
/// here without updating `record_http` will break compilation, which is
|
|
||||||
/// exactly the safety we want — Prometheus alerts on cardinality drift,
|
|
||||||
/// so accidental new label values matter.
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub enum HttpRoute {
|
|
||||||
BootScript,
|
|
||||||
IsoRange,
|
|
||||||
IsoInner,
|
|
||||||
IpxeBinary,
|
|
||||||
Api,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn render_emits_each_metric_family_once() {
|
|
||||||
let m = Metrics::new();
|
|
||||||
m.record_dhcp_reply("uefi-x64");
|
|
||||||
m.record_dhcp_reply("bios");
|
|
||||||
m.record_tftp_ok(1024);
|
|
||||||
m.record_http(HttpRoute::Api);
|
|
||||||
m.set_iso_count(3);
|
|
||||||
let out = m.render("0.2.0", 42);
|
|
||||||
assert_eq!(out.matches("# TYPE pxeforge_dhcp_replies_total counter").count(), 1);
|
|
||||||
assert_eq!(out.matches("# TYPE pxeforge_iso_count gauge").count(), 1);
|
|
||||||
assert!(out.contains("pxeforge_dhcp_replies_total{arch=\"uefi\"} 1"));
|
|
||||||
assert!(out.contains("pxeforge_dhcp_replies_total{arch=\"bios\"} 1"));
|
|
||||||
assert!(out.contains("pxeforge_tftp_transfers_total{status=\"ok\"} 1"));
|
|
||||||
assert!(out.contains("pxeforge_tftp_bytes_total 1024"));
|
|
||||||
assert!(out.contains("pxeforge_http_requests_total{route=\"api\"} 1"));
|
|
||||||
assert!(out.contains("pxeforge_iso_count 3"));
|
|
||||||
assert!(out.contains("pxeforge_uptime_seconds 42"));
|
|
||||||
assert!(out.contains("pxeforge_build_info{version=\"0.2.0\"} 1"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cloned_metrics_share_state() {
|
|
||||||
let a = Metrics::new();
|
|
||||||
let b = a.clone();
|
|
||||||
a.record_dhcp_reply("bios");
|
|
||||||
b.record_dhcp_reply("bios");
|
|
||||||
let out = a.render("test", 0);
|
|
||||||
assert!(out.contains("pxeforge_dhcp_replies_total{arch=\"bios\"} 2"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -41,14 +41,7 @@ pub struct ReplyContext<'a> {
|
|||||||
pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
|
pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
|
||||||
match ctx.class {
|
match ctx.class {
|
||||||
FirmwareClass::IpxeUserClass => BootDirective::HttpScript {
|
FirmwareClass::IpxeUserClass => BootDirective::HttpScript {
|
||||||
// Pass the client's MAC in the query string so the HTTP
|
url: format!("{}/boot.ipxe", ctx.public_base_url.trim_end_matches('/')),
|
||||||
// layer can short-circuit to a per-MAC binding when one
|
|
||||||
// exists. iPXE substitutes `${mac}` literally before issuing
|
|
||||||
// the GET, so this stays static across firmwares.
|
|
||||||
url: format!(
|
|
||||||
"{}/boot.ipxe?mac=${{mac}}",
|
|
||||||
ctx.public_base_url.trim_end_matches('/')
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
FirmwareClass::HttpClient => {
|
FirmwareClass::HttpClient => {
|
||||||
// UEFI HTTP boot: client wants an http:// URL in option 67
|
// UEFI HTTP boot: client wants an http:// URL in option 67
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ pub struct DhcpProxyServer {
|
|||||||
our_ip: Ipv4Addr,
|
our_ip: Ipv4Addr,
|
||||||
public_base_url: String,
|
public_base_url: String,
|
||||||
clients: Arc<ClientRegistry>,
|
clients: Arc<ClientRegistry>,
|
||||||
metrics: pxeforge_core::Metrics,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DhcpProxyServer {
|
impl DhcpProxyServer {
|
||||||
@@ -30,17 +29,8 @@ impl DhcpProxyServer {
|
|||||||
our_ip: Ipv4Addr,
|
our_ip: Ipv4Addr,
|
||||||
public_base_url: String,
|
public_base_url: String,
|
||||||
clients: Arc<ClientRegistry>,
|
clients: Arc<ClientRegistry>,
|
||||||
metrics: pxeforge_core::Metrics,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self { bind, dhcp_port, pxe_port, our_ip, public_base_url, clients }
|
||||||
bind,
|
|
||||||
dhcp_port,
|
|
||||||
pxe_port,
|
|
||||||
our_ip,
|
|
||||||
public_base_url,
|
|
||||||
clients,
|
|
||||||
metrics,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run(self) -> anyhow::Result<()> {
|
pub async fn run(self) -> anyhow::Result<()> {
|
||||||
@@ -126,14 +116,12 @@ impl DhcpProxyServer {
|
|||||||
};
|
};
|
||||||
let directive = decide(&ctx);
|
let directive = decide(&ctx);
|
||||||
if matches!(directive, BootDirective::Ignore) {
|
if matches!(directive, BootDirective::Ignore) {
|
||||||
self.metrics.record_dhcp_decline();
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
target: "pxeforge::dhcp",
|
target: "pxeforge::dhcp",
|
||||||
mac=%mac, arch=?arch, "ignoring — no bootfile for arch"
|
mac=%mac, arch=?arch, "ignoring — no bootfile for arch"
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
self.metrics.record_dhcp_reply(arch.as_str());
|
|
||||||
|
|
||||||
let Some(reply) = build_reply(&ctx, &directive) else { return Ok(()); };
|
let Some(reply) = build_reply(&ctx, &directive) else { return Ok(()); };
|
||||||
let mut out = Vec::with_capacity(512);
|
let mut out = Vec::with_capacity(512);
|
||||||
|
|||||||
+4
-159
@@ -45,7 +45,6 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.route("/assets/app.js", get(ui_js))
|
.route("/assets/app.js", get(ui_js))
|
||||||
.route("/assets/app.css", get(ui_css))
|
.route("/assets/app.css", get(ui_css))
|
||||||
.route("/assets/logo.svg", get(ui_logo))
|
.route("/assets/logo.svg", get(ui_logo))
|
||||||
.route("/assets/anvil-forge.svg", get(ui_anvil_forge))
|
|
||||||
// iPXE script endpoints.
|
// iPXE script endpoints.
|
||||||
.route("/boot.ipxe", get(boot_top_menu))
|
.route("/boot.ipxe", get(boot_top_menu))
|
||||||
.route("/boot/:filename", get(boot_sub))
|
.route("/boot/:filename", get(boot_sub))
|
||||||
@@ -81,14 +80,6 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.route("/api/log/clear", post(log_stream::clear))
|
.route("/api/log/clear", post(log_stream::clear))
|
||||||
// Phase 4: operator terminal commands (whitelisted).
|
// Phase 4: operator terminal commands (whitelisted).
|
||||||
.route("/api/terminal", post(terminal::run_command))
|
.route("/api/terminal", post(terminal::run_command))
|
||||||
// Phase 5: per-MAC host bindings (Tinkerbell-style). Operator
|
|
||||||
// pins a MAC to a boot entry; /boot.ipxe?mac=... chains directly.
|
|
||||||
.route("/api/hosts", get(api_hosts_list).post(api_hosts_upsert))
|
|
||||||
.route("/api/hosts/:mac", delete(api_hosts_remove))
|
|
||||||
// Phase 5: Prometheus scrape endpoint. Plain text exposition
|
|
||||||
// format. No auth — the metrics surface is intentionally
|
|
||||||
// boring (counts, no payloads).
|
|
||||||
.route("/metrics", get(api_metrics))
|
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
// 16 GiB upload cap — ISOs are big; chunks stream so this isn't memory use.
|
// 16 GiB upload cap — ISOs are big; chunks stream so this isn't memory use.
|
||||||
.layer(DefaultBodyLimit::max(16 * 1024 * 1024 * 1024))
|
.layer(DefaultBodyLimit::max(16 * 1024 * 1024 * 1024))
|
||||||
@@ -124,13 +115,6 @@ async fn ui_logo() -> Response {
|
|||||||
).into_response()
|
).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ui_anvil_forge() -> Response {
|
|
||||||
(
|
|
||||||
[(header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml"))],
|
|
||||||
pxeforge_webui::anvil_forge_svg(),
|
|
||||||
).into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── iPXE scripts ──────────────────────────────────────────────────────────
|
// ─── iPXE scripts ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn text_plain(body: String) -> Response {
|
fn text_plain(body: String) -> Response {
|
||||||
@@ -138,51 +122,10 @@ fn text_plain(body: String) -> Response {
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Top-level boot script. Honors per-MAC host bindings: if the
|
async fn boot_top_menu(State(state): State<AppState>) -> Response {
|
||||||
/// requesting client carries a `?mac=...` query param (iPXE's `${mac}`
|
|
||||||
/// substitution) and that MAC has a binding, we short-circuit straight
|
|
||||||
/// to the bound target instead of rendering the menu.
|
|
||||||
async fn boot_top_menu(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Query(p): Query<BootMenuParams>,
|
|
||||||
) -> Response {
|
|
||||||
state.metrics.record_http(pxeforge_core::HttpRoute::BootScript);
|
|
||||||
let isos = state.iso_store.list();
|
let isos = state.iso_store.list();
|
||||||
let settings = state.settings.snapshot();
|
let settings = state.settings.snapshot();
|
||||||
let base = &state.public_base_url;
|
text_plain(render_menu(&isos, &settings, &state.public_base_url))
|
||||||
|
|
||||||
// Per-MAC override: if the client identified itself and we have a
|
|
||||||
// binding, chain directly. The chain target falls back to the menu
|
|
||||||
// on failure so a stale / misconfigured binding can't lock a client
|
|
||||||
// out — it just shows the menu.
|
|
||||||
if let Some(mac) = p.mac.as_deref() {
|
|
||||||
if let Some(binding) = state.hosts.lookup(mac) {
|
|
||||||
tracing::info!(
|
|
||||||
target: "pxeforge::http",
|
|
||||||
mac = %binding.mac, target = %binding.target,
|
|
||||||
"host binding applied"
|
|
||||||
);
|
|
||||||
let target = binding.target;
|
|
||||||
// Reserved menu shortcuts are emitted as `_xxx`; per-entry
|
|
||||||
// boot scripts are at `/boot/<id>.ipxe`. Both share the same
|
|
||||||
// `/boot/<name>` route, so the URL is identical.
|
|
||||||
return text_plain(format!(
|
|
||||||
"#!ipxe\n\
|
|
||||||
echo PXEForge: per-MAC binding -> {target}\n\
|
|
||||||
chain {base}/boot/{target}.ipxe || chain {base}/boot.ipxe\n"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
text_plain(render_menu(&isos, &settings, base))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct BootMenuParams {
|
|
||||||
/// Client MAC, supplied by iPXE via `${mac}` variable in
|
|
||||||
/// `chain ${prefix}/boot.ipxe?mac=${mac}`. Optional — if absent we
|
|
||||||
/// fall back to the menu unconditionally.
|
|
||||||
mac: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn boot_sub(
|
async fn boot_sub(
|
||||||
@@ -261,7 +204,7 @@ async fn iso_file(
|
|||||||
return (StatusCode::NOT_FOUND, "no such iso").into_response();
|
return (StatusCode::NOT_FOUND, "no such iso").into_response();
|
||||||
};
|
};
|
||||||
let p = iso_path.clone();
|
let p = iso_path.clone();
|
||||||
let in_path = format!("/{path}");
|
let in_path = format!("/{}", path);
|
||||||
let loc = tokio::task::spawn_blocking(move || iso_fs::lookup(&p, &in_path))
|
let loc = tokio::task::spawn_blocking(move || iso_fs::lookup(&p, &in_path))
|
||||||
.await.ok().flatten();
|
.await.ok().flatten();
|
||||||
let Some(loc) = loc else {
|
let Some(loc) = loc else {
|
||||||
@@ -419,27 +362,18 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
|
|||||||
let nfs = state.nfs.list();
|
let nfs = state.nfs.list();
|
||||||
let nfs_active = nfs.iter().filter(|m| m.mounted).count();
|
let nfs_active = nfs.iter().filter(|m| m.mounted).count();
|
||||||
let isos = state.iso_store.list();
|
let isos = state.iso_store.list();
|
||||||
let clients = state.clients.list();
|
|
||||||
let gates = state.gates.list();
|
let gates = state.gates.list();
|
||||||
// Phase 4: dashboard tracks "imaging" as gates with an assignment
|
// Phase 4: dashboard tracks "imaging" as gates with an assignment
|
||||||
// already issued — they're the ones actively chaining a boot script.
|
// already issued — they're the ones actively chaining a boot script.
|
||||||
let imaging = gates.iter().filter(|g| g.assigned_target.is_some()).count();
|
let imaging = gates.iter().filter(|g| g.assigned_target.is_some()).count();
|
||||||
let waiting = gates.len() - imaging;
|
let waiting = gates.len() - imaging;
|
||||||
// Side-effect: push gauge values out to the Prometheus surface.
|
|
||||||
// Doing it here (in the most-frequently-polled endpoint) keeps the
|
|
||||||
// gauges fresh without a dedicated scrape-time hook.
|
|
||||||
state.metrics.set_iso_count(isos.len() as u64);
|
|
||||||
state.metrics.set_client_count(clients.len() as u64);
|
|
||||||
state.metrics.set_gate_counts(gates.len() as u64, imaging as u64);
|
|
||||||
state.metrics.set_nfs_active(nfs_active as u64);
|
|
||||||
state.metrics.record_http(pxeforge_core::HttpRoute::Api);
|
|
||||||
let now = time::OffsetDateTime::now_utc();
|
let now = time::OffsetDateTime::now_utc();
|
||||||
let uptime_secs = (now - state.started_at).whole_seconds().max(0);
|
let uptime_secs = (now - state.started_at).whole_seconds().max(0);
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"version": env!("CARGO_PKG_VERSION"),
|
"version": env!("CARGO_PKG_VERSION"),
|
||||||
"public_base_url": state.public_base_url,
|
"public_base_url": state.public_base_url,
|
||||||
"iso_count": isos.len(),
|
"iso_count": isos.len(),
|
||||||
"client_count": clients.len(),
|
"client_count": state.clients.list().len(),
|
||||||
"gate_count": gates.len(),
|
"gate_count": gates.len(),
|
||||||
"imaging_count": imaging,
|
"imaging_count": imaging,
|
||||||
"waiting_count": waiting,
|
"waiting_count": waiting,
|
||||||
@@ -448,7 +382,6 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
|
|||||||
"smb": smb,
|
"smb": smb,
|
||||||
"nfs_count": nfs.len(),
|
"nfs_count": nfs.len(),
|
||||||
"nfs_active": nfs_active,
|
"nfs_active": nfs_active,
|
||||||
"host_bindings": state.hosts.len(),
|
|
||||||
"uptime_secs": uptime_secs,
|
"uptime_secs": uptime_secs,
|
||||||
"started_at": state.started_at,
|
"started_at": state.started_at,
|
||||||
"nic_name": state.nic_name,
|
"nic_name": state.nic_name,
|
||||||
@@ -717,94 +650,6 @@ async fn api_network_put(
|
|||||||
StatusCode::NO_CONTENT
|
StatusCode::NO_CONTENT
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Per-MAC host bindings ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async fn api_hosts_list(State(state): State<AppState>) -> Json<serde_json::Value> {
|
|
||||||
Json(json!({ "hosts": state.hosts.list() }))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct HostsUpsertBody {
|
|
||||||
mac: String,
|
|
||||||
target: String,
|
|
||||||
#[serde(default)]
|
|
||||||
label: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn api_hosts_upsert(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(body): Json<HostsUpsertBody>,
|
|
||||||
) -> Response {
|
|
||||||
let mac = body.mac.trim();
|
|
||||||
if mac.is_empty() {
|
|
||||||
return (StatusCode::BAD_REQUEST, "mac is required").into_response();
|
|
||||||
}
|
|
||||||
// Sanity-check the target if the operator supplied a real boot
|
|
||||||
// entry id (anything starting with `_` is a reserved menu shortcut
|
|
||||||
// and exists by definition).
|
|
||||||
let target = body.target.trim();
|
|
||||||
if !target.starts_with('_')
|
|
||||||
&& !state
|
|
||||||
.iso_store
|
|
||||||
.list()
|
|
||||||
.into_iter()
|
|
||||||
.any(|i| i.boot_entries.iter().any(|e| e.id == target))
|
|
||||||
{
|
|
||||||
return (
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
format!("unknown boot entry: {target}"),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
let binding = state.hosts.upsert(mac, target, body.label.trim());
|
|
||||||
(StatusCode::CREATED, Json(binding)).into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn api_hosts_remove(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
AxumPath(mac): AxumPath<String>,
|
|
||||||
) -> StatusCode {
|
|
||||||
if state.hosts.remove(&mac) {
|
|
||||||
StatusCode::NO_CONTENT
|
|
||||||
} else {
|
|
||||||
StatusCode::NOT_FOUND
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Prometheus metrics ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async fn api_metrics(State(state): State<AppState>) -> Response {
|
|
||||||
// Refresh gauges from live state before rendering — keeps the
|
|
||||||
// scrape "honest" without making /api/status the only path that
|
|
||||||
// updates them.
|
|
||||||
state
|
|
||||||
.metrics
|
|
||||||
.set_iso_count(state.iso_store.list().len() as u64);
|
|
||||||
state
|
|
||||||
.metrics
|
|
||||||
.set_client_count(state.clients.list().len() as u64);
|
|
||||||
let gates = state.gates.list();
|
|
||||||
let imaging = gates.iter().filter(|g| g.assigned_target.is_some()).count();
|
|
||||||
state
|
|
||||||
.metrics
|
|
||||||
.set_gate_counts(gates.len() as u64, imaging as u64);
|
|
||||||
state
|
|
||||||
.metrics
|
|
||||||
.set_nfs_active(state.nfs.list().iter().filter(|m| m.mounted).count() as u64);
|
|
||||||
|
|
||||||
let now = time::OffsetDateTime::now_utc();
|
|
||||||
let uptime = (now - state.started_at).whole_seconds().max(0) as u64;
|
|
||||||
let body = state.metrics.render(env!("CARGO_PKG_VERSION"), uptime);
|
|
||||||
(
|
|
||||||
[(
|
|
||||||
header::CONTENT_TYPE,
|
|
||||||
HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"),
|
|
||||||
)],
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
|
|||||||
let base = base_url.trim_end_matches('/');
|
let base = base_url.trim_end_matches('/');
|
||||||
let timeout_ms = settings.boot_menu_timeout_secs.saturating_mul(1000);
|
let timeout_ms = settings.boot_menu_timeout_secs.saturating_mul(1000);
|
||||||
let default_item = match settings.timeout_action {
|
let default_item = match settings.timeout_action {
|
||||||
|
TimeoutAction::LocalHdd => "local",
|
||||||
TimeoutAction::GatedDeployment => "gate",
|
TimeoutAction::GatedDeployment => "gate",
|
||||||
// Stay -> iPXE's `--timeout 0` is "no timeout". Pick any default
|
// Stay -> iPXE's `--timeout 0` is "no timeout". Pick any default
|
||||||
// label; the client waits for keypress. We use the same label as
|
// label; the client waits for keypress.
|
||||||
// LocalHdd to keep the menu's pre-highlight stable.
|
TimeoutAction::Stay => "local",
|
||||||
TimeoutAction::LocalHdd | TimeoutAction::Stay => "local",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let _ = writeln!(s, "#!ipxe");
|
let _ = writeln!(s, "#!ipxe");
|
||||||
@@ -135,7 +135,7 @@ pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) ->
|
|||||||
/// operator expectations from the original tool).
|
/// operator expectations from the original tool).
|
||||||
fn fmt_size_mib(bytes: u64) -> String {
|
fn fmt_size_mib(bytes: u64) -> String {
|
||||||
let mib = bytes / (1024 * 1024);
|
let mib = bytes / (1024 * 1024);
|
||||||
format!("{mib} MB")
|
format!("{} MB", mib)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assign `--key N <id>` hotkeys 1..9, then nothing for positions >=9.
|
/// Assign `--key N <id>` hotkeys 1..9, then nothing for positions >=9.
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
//! - `/ipxe/<file>` bundled iPXE binaries (for UEFI HTTP boot)
|
//! - `/ipxe/<file>` bundled iPXE binaries (for UEFI HTTP boot)
|
||||||
//! - `/iso/<id>.iso` raw ISO file (with Range support)
|
//! - `/iso/<id>.iso` raw ISO file (with Range support)
|
||||||
//! - `/iso/<id>/<path>` files inside the ISO (for wimboot WIM fetches
|
//! - `/iso/<id>/<path>` files inside the ISO (for wimboot WIM fetches
|
||||||
//! and Linux kernel/initrd, without having to
|
//! and Linux kernel/initrd, without having to
|
||||||
//! re-extract on every request)
|
//! re-extract on every request)
|
||||||
//!
|
//!
|
||||||
//! The `<id>/<path>` handler uses a read-only ISO9660 shim (see `iso_fs`)
|
//! The `<id>/<path>` handler uses a read-only ISO9660 shim (see `iso_fs`)
|
||||||
//! that lseeks into the ISO on disk — so we never keep extracted copies.
|
//! that lseeks into the ISO on disk — so we never keep extracted copies.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use pxeforge_core::{ClientRegistry, GateQueue, HostBindings, LogBus, Metrics, SettingsStore};
|
use pxeforge_core::{ClientRegistry, GateQueue, LogBus, SettingsStore};
|
||||||
use pxeforge_iso_store::{IsoStore, NfsManager, SmbManager};
|
use pxeforge_iso_store::{IsoStore, NfsManager, SmbManager};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
@@ -9,13 +9,6 @@ pub struct AppState {
|
|||||||
pub clients: Arc<ClientRegistry>,
|
pub clients: Arc<ClientRegistry>,
|
||||||
pub settings: Arc<SettingsStore>,
|
pub settings: Arc<SettingsStore>,
|
||||||
pub gates: Arc<GateQueue>,
|
pub gates: Arc<GateQueue>,
|
||||||
/// Per-MAC iPXE script overrides. When a client matching one of
|
|
||||||
/// these MACs requests `/boot.ipxe`, we chain straight to the
|
|
||||||
/// configured target instead of rendering the menu.
|
|
||||||
pub hosts: HostBindings,
|
|
||||||
/// Lock-free metrics counters surfaced at `/metrics` in Prometheus
|
|
||||||
/// text format. Cheap to clone (handles to atomics).
|
|
||||||
pub metrics: Metrics,
|
|
||||||
/// Optional SMB manager. Present when the binary was given a writable
|
/// Optional SMB manager. Present when the binary was given a writable
|
||||||
/// `smb_dir` at startup; `None` in pure-Linux-only deployments where
|
/// `smb_dir` at startup; `None` in pure-Linux-only deployments where
|
||||||
/// Windows support is not wired in. Settings toggle drives start/stop.
|
/// Windows support is not wired in. Settings toggle drives start/stop.
|
||||||
|
|||||||
@@ -161,8 +161,8 @@ fn clients_text(s: &AppState) -> String {
|
|||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
"{:<19} {:<16} {:<8} LAST SEEN",
|
"{:<19} {:<16} {:<8} {}",
|
||||||
"MAC", "IP", "EVENTS"
|
"MAC", "IP", "EVENTS", "LAST SEEN"
|
||||||
);
|
);
|
||||||
for c in clients {
|
for c in clients {
|
||||||
let ip = c.last_ip.map_or_else(|| "-".into(), |i| i.to_string());
|
let ip = c.last_ip.map_or_else(|| "-".into(), |i| i.to_string());
|
||||||
@@ -259,8 +259,8 @@ async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
|
|||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
"{:<24} {:<6} {:<7} {:<6} TARGET",
|
"{:<24} {:<6} {:<7} {:<6} {}",
|
||||||
"ID", "VER", "STATUS", "ISOS"
|
"ID", "VER", "STATUS", "ISOS", "TARGET"
|
||||||
);
|
);
|
||||||
for m in mounts {
|
for m in mounts {
|
||||||
let status = if m.mounted { "ok" } else { "down" };
|
let status = if m.mounted { "ok" } else { "down" };
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::http::{header, Request, StatusCode};
|
use axum::http::{header, Request, StatusCode};
|
||||||
use pxeforge_core::{ClientRegistry, GateQueue, HostBindings, LogBus, Metrics, SettingsStore};
|
use pxeforge_core::{ClientRegistry, GateQueue, LogBus, SettingsStore};
|
||||||
use pxeforge_http_api::{build_router, AppState};
|
use pxeforge_http_api::{build_router, AppState};
|
||||||
use pxeforge_iso_store::{IsoStore, NfsManager};
|
use pxeforge_iso_store::{IsoStore, NfsManager};
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
@@ -92,15 +92,11 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
|||||||
let nfs = NfsManager::new(dir.path(), iso_store.clone());
|
let nfs = NfsManager::new(dir.path(), iso_store.clone());
|
||||||
iso_store.set_nfs_root(nfs.mount_root());
|
iso_store.set_nfs_root(nfs.mount_root());
|
||||||
let log_bus = LogBus::new(64);
|
let log_bus = LogBus::new(64);
|
||||||
let hosts = HostBindings::load_or_default(dir.path());
|
|
||||||
let metrics = Metrics::new();
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
iso_store,
|
iso_store,
|
||||||
clients,
|
clients,
|
||||||
gates,
|
gates,
|
||||||
settings,
|
settings,
|
||||||
hosts,
|
|
||||||
metrics,
|
|
||||||
smb: None,
|
smb: None,
|
||||||
nfs,
|
nfs,
|
||||||
log_bus,
|
log_bus,
|
||||||
@@ -240,8 +236,8 @@ async fn gated_deployment_full_flow() {
|
|||||||
assert!(assign_json.contains(r#""assigned":1"#), "assign response: {assign_json}");
|
assert!(assign_json.contains(r#""assigned":1"#), "assign response: {assign_json}");
|
||||||
|
|
||||||
// Now assign to gate 1 too so the background poll wakes.
|
// Now assign to gate 1 too so the background poll wakes.
|
||||||
let body = r#"{"target":"fake-alpine-linux","gate_ids":[]}"#;
|
let body = format!(r#"{{"target":"fake-alpine-linux","gate_ids":[]}}"#);
|
||||||
post_json(&app, "/api/gate/assign", body).await;
|
post_json(&app, "/api/gate/assign", &body).await;
|
||||||
|
|
||||||
let (poll_status, poll_body) = poll_future.await.unwrap();
|
let (poll_status, poll_body) = poll_future.await.unwrap();
|
||||||
assert_eq!(poll_status, StatusCode::OK);
|
assert_eq!(poll_status, StatusCode::OK);
|
||||||
@@ -329,7 +325,6 @@ async fn ui_assets_served_offline() {
|
|||||||
("/assets/app.js", "application/javascript"),
|
("/assets/app.js", "application/javascript"),
|
||||||
("/assets/app.css", "text/css"),
|
("/assets/app.css", "text/css"),
|
||||||
("/assets/logo.svg", "image/svg+xml"),
|
("/assets/logo.svg", "image/svg+xml"),
|
||||||
("/assets/anvil-forge.svg", "image/svg+xml"),
|
|
||||||
] {
|
] {
|
||||||
let res = app
|
let res = app
|
||||||
.clone()
|
.clone()
|
||||||
@@ -443,205 +438,6 @@ async fn log_recent_returns_buffered_lines() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
|
|
||||||
// Synthesize an ISO with a Windows volume label + the sources/boot.wim
|
|
||||||
// sentinel so introspection labels it WindowsPe with has_boot_wim.
|
|
||||||
let mut buf = vec![0u8; 32 * 2048];
|
|
||||||
let off = 16 * 2048;
|
|
||||||
buf[off] = 0x01;
|
|
||||||
buf[off + 1..off + 6].copy_from_slice(b"CD001");
|
|
||||||
buf[off + 6] = 0x01;
|
|
||||||
let label = b"WIN11_X64".to_vec();
|
|
||||||
let mut padded = label.clone();
|
|
||||||
padded.resize(32, b' ');
|
|
||||||
buf[off + 40..off + 40 + 32].copy_from_slice(&padded);
|
|
||||||
// Sprinkle the sources/boot.wim sentinel where the introspection
|
|
||||||
// scanner will find it (anywhere in the first 64 MB).
|
|
||||||
let sentinel = b"SOURCES\\BOOT.WIM";
|
|
||||||
buf.extend_from_slice(sentinel);
|
|
||||||
let term = 17 * 2048;
|
|
||||||
buf[term] = 0xFF;
|
|
||||||
buf[term + 1..term + 6].copy_from_slice(b"CD001");
|
|
||||||
buf[term + 6] = 0x01;
|
|
||||||
|
|
||||||
let (state, _dir) = build_state().await;
|
|
||||||
let app = build_router(state);
|
|
||||||
|
|
||||||
// Need windows_enabled for the Windows path to render in the menu.
|
|
||||||
let res = app
|
|
||||||
.clone()
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.method("PUT")
|
|
||||||
.uri("/api/settings")
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.body(Body::from(
|
|
||||||
r#"{"boot_menu_timeout_secs":600,"timeout_action":"gated_deployment",
|
|
||||||
"windows_enabled":false,"smb_host_override":"","extra_kernel_args":"",
|
|
||||||
"default_local_hdd":true,"gate_wait_max_secs":0,"dns_server":""}"#
|
|
||||||
.to_string(),
|
|
||||||
))
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
// wimboot binary is bundled in this repo so windows_enabled=true should
|
|
||||||
// not be rejected; we leave it false to keep the upload path agnostic.
|
|
||||||
assert_eq!(res.status(), StatusCode::NO_CONTENT);
|
|
||||||
|
|
||||||
let (ct, body) = multipart_iso_body("Win11_x64.iso", &buf);
|
|
||||||
let res = app
|
|
||||||
.clone()
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.uri("/api/isos")
|
|
||||||
.header("content-type", ct)
|
|
||||||
.body(Body::from(body))
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(res.status(), StatusCode::CREATED);
|
|
||||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
|
|
||||||
let meta: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
||||||
assert_eq!(meta["introspection"]["family"], "windows_pe");
|
|
||||||
assert!(
|
|
||||||
meta["introspection"]["has_boot_wim"].as_bool().unwrap(),
|
|
||||||
"introspection should detect sources/boot.wim sentinel"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The boot entry should be a wimboot kind with the canonical 5-file
|
|
||||||
// chain documented in the LinusTechTips iPXE-Windows guide.
|
|
||||||
let entry = &meta["boot_entries"][0];
|
|
||||||
assert_eq!(entry["kind"]["kind"], "wimboot");
|
|
||||||
let files = entry["kind"]["files"].as_array().unwrap();
|
|
||||||
let names: Vec<&str> = files.iter().map(|f| f[0].as_str().unwrap()).collect();
|
|
||||||
assert!(names.contains(&"bootmgr"));
|
|
||||||
assert!(names.contains(&"bootmgr.efi"));
|
|
||||||
assert!(names.contains(&"bcd"));
|
|
||||||
assert!(names.contains(&"boot.sdi"));
|
|
||||||
assert!(names.contains(&"boot.wim"));
|
|
||||||
|
|
||||||
// Render the entry script and verify:
|
|
||||||
// 1. It uses wimboot
|
|
||||||
// 2. All 5 files are referenced via `initrd --name`
|
|
||||||
// 3. NO trust-store / driver / testsigning operations slip in
|
|
||||||
let entry_id = entry["id"].as_str().unwrap();
|
|
||||||
let url = format!("/boot/{entry_id}.ipxe");
|
|
||||||
let (s, body) = get(&app, &url).await;
|
|
||||||
assert_eq!(s, StatusCode::OK);
|
|
||||||
let script = String::from_utf8(body).unwrap();
|
|
||||||
assert!(script.contains("kernel "), "missing kernel line:\n{script}");
|
|
||||||
assert!(script.contains("ipxe/wimboot"), "missing wimboot loader:\n{script}");
|
|
||||||
for tag in ["bootmgr", "bootmgr.efi", "bcd", "boot.sdi", "boot.wim"] {
|
|
||||||
assert!(
|
|
||||||
script.contains(&format!("initrd --name {tag}")),
|
|
||||||
"missing `initrd --name {tag}` line:\n{script}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Hard guarantees we never want to see in any client-facing script.
|
|
||||||
let lower = script.to_lowercase();
|
|
||||||
for forbidden in [
|
|
||||||
"bcdedit", "testsigning", "certutil", "test-signed",
|
|
||||||
"httpdisk", "/set testsigning",
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
!lower.contains(forbidden),
|
|
||||||
"forbidden trust-store operation `{forbidden}` in script:\n{script}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn host_binding_short_circuits_boot_menu() {
|
|
||||||
let (state, _dir) = build_state().await;
|
|
||||||
let app = build_router(state.clone());
|
|
||||||
|
|
||||||
// Pin a MAC to the reserved local-hdd boot shortcut. `_local` is a
|
|
||||||
// built-in target so the upsert validator accepts it without
|
|
||||||
// requiring a real ISO.
|
|
||||||
let res = app
|
|
||||||
.clone()
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.uri("/api/hosts")
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.body(Body::from(
|
|
||||||
r#"{"mac":"AA:BB:CC:00:00:01","target":"_local","label":"toms-laptop"}"#
|
|
||||||
.to_string(),
|
|
||||||
))
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(res.status(), StatusCode::CREATED);
|
|
||||||
|
|
||||||
// Hit /boot.ipxe with the bound MAC and assert we get the
|
|
||||||
// short-circuit chain instead of the menu.
|
|
||||||
let (s1, b1) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:01").await;
|
|
||||||
assert_eq!(s1, StatusCode::OK);
|
|
||||||
let body1 = String::from_utf8(b1).unwrap();
|
|
||||||
assert!(
|
|
||||||
body1.contains("per-MAC binding"),
|
|
||||||
"expected MAC short-circuit, got:\n{body1}"
|
|
||||||
);
|
|
||||||
assert!(body1.contains("/boot/_local.ipxe"));
|
|
||||||
|
|
||||||
// And a different MAC still gets the menu.
|
|
||||||
let (s2, b2) = get(&app, "/boot.ipxe?mac=ff:ff:ff:ff:ff:ff").await;
|
|
||||||
assert_eq!(s2, StatusCode::OK);
|
|
||||||
let body2 = String::from_utf8(b2).unwrap();
|
|
||||||
assert!(
|
|
||||||
body2.contains("menu") || body2.contains("Default"),
|
|
||||||
"expected interactive menu, got:\n{body2}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn metrics_endpoint_emits_prometheus_format() {
|
|
||||||
let (state, _dir) = build_state().await;
|
|
||||||
let app = build_router(state);
|
|
||||||
// Drive a couple of paths so counters move off zero.
|
|
||||||
let _ = get(&app, "/api/status").await;
|
|
||||||
let _ = get(&app, "/boot.ipxe").await;
|
|
||||||
|
|
||||||
let res = app
|
|
||||||
.clone()
|
|
||||||
.oneshot(Request::builder().uri("/metrics").body(Body::empty()).unwrap())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(res.status(), StatusCode::OK);
|
|
||||||
let ct = res.headers().get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
|
|
||||||
assert!(
|
|
||||||
ct.starts_with("text/plain"),
|
|
||||||
"wrong content-type: {ct}"
|
|
||||||
);
|
|
||||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
|
|
||||||
let body = String::from_utf8(body.to_vec()).unwrap();
|
|
||||||
// Spot-check the must-have metric families.
|
|
||||||
for name in [
|
|
||||||
"pxeforge_dhcp_replies_total",
|
|
||||||
"pxeforge_tftp_transfers_total",
|
|
||||||
"pxeforge_http_requests_total",
|
|
||||||
"pxeforge_iso_count",
|
|
||||||
"pxeforge_uptime_seconds",
|
|
||||||
"pxeforge_build_info",
|
|
||||||
] {
|
|
||||||
assert!(body.contains(name), "missing metric {name} in:\n{body}");
|
|
||||||
}
|
|
||||||
// Each name appears exactly once as a `# TYPE` declaration.
|
|
||||||
for name in [
|
|
||||||
"pxeforge_dhcp_replies_total",
|
|
||||||
"pxeforge_iso_count",
|
|
||||||
] {
|
|
||||||
let count = body.matches(&format!("# TYPE {name}")).count();
|
|
||||||
assert_eq!(count, 1, "{name} TYPE line appears {count} times");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn network_endpoint_exposes_dns_round_trip() {
|
async fn network_endpoint_exposes_dns_round_trip() {
|
||||||
let (state, _dir) = build_state().await;
|
let (state, _dir) = build_state().await;
|
||||||
|
|||||||
@@ -40,18 +40,10 @@ pub fn asset_bytes(name: &str) -> Option<Vec<u8>> {
|
|||||||
IpxeAssets::get(name).map(|f| f.data.into_owned())
|
IpxeAssets::get(name).map(|f| f.data.into_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Same as [`asset_bytes`] but returns the embedded slice directly,
|
|
||||||
/// avoiding the heap copy when the caller only needs to read the
|
|
||||||
/// payload. Falls back to None for unknown names.
|
|
||||||
#[must_use]
|
|
||||||
pub fn asset_slice(name: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
|
|
||||||
IpxeAssets::get(name).map(|f| f.data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enumerate embedded asset filenames. Useful for startup logging so the
|
/// Enumerate embedded asset filenames. Useful for startup logging so the
|
||||||
/// operator can immediately tell which architectures will work.
|
/// operator can immediately tell which architectures will work.
|
||||||
pub fn list_assets() -> Vec<String> {
|
pub fn list_assets() -> Vec<String> {
|
||||||
IpxeAssets::iter().map(std::borrow::Cow::into_owned).collect()
|
IpxeAssets::iter().map(|c| c.into_owned()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log at startup which iPXE binaries are present and which are missing.
|
/// Log at startup which iPXE binaries are present and which are missing.
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ pub fn introspect(path: &Path) -> IntrospectionReport {
|
|||||||
// that happens in the store after introspection.
|
// that happens in the store after introspection.
|
||||||
let (k, i) = guess_kernel_initrd(report.family);
|
let (k, i) = guess_kernel_initrd(report.family);
|
||||||
report.kernel_path = k.map(str::to_string);
|
report.kernel_path = k.map(str::to_string);
|
||||||
report.initrd_paths = i.iter().map(std::string::ToString::to_string).collect();
|
report.initrd_paths = i.iter().map(|s| s.to_string()).collect();
|
||||||
|
|
||||||
report
|
report
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-18
@@ -87,17 +87,13 @@ impl SmbManager {
|
|||||||
/// Write out `smb.conf` for the currently-discovered shares. Safe to
|
/// Write out `smb.conf` for the currently-discovered shares. Safe to
|
||||||
/// call while smbd is running — smbd reloads on SIGHUP.
|
/// call while smbd is running — smbd reloads on SIGHUP.
|
||||||
pub fn write_conf(&self) -> std::io::Result<Vec<String>> {
|
pub fn write_conf(&self) -> std::io::Result<Vec<String>> {
|
||||||
use std::fmt::Write as _;
|
|
||||||
std::fs::create_dir_all(&self.smb_dir)?;
|
std::fs::create_dir_all(&self.smb_dir)?;
|
||||||
let shares = self.discover_shares();
|
let shares = self.discover_shares();
|
||||||
let mut conf = String::new();
|
let mut conf = String::new();
|
||||||
conf.push_str(SMB_CONF_GLOBAL);
|
conf.push_str(SMB_CONF_GLOBAL);
|
||||||
for name in &shares {
|
for name in &shares {
|
||||||
let path = self.smb_dir.join(name);
|
let path = self.smb_dir.join(name);
|
||||||
// Per-share block. `write!` to String never fails — the unwrap
|
conf.push_str(&format!(
|
||||||
// is provably unreachable, but expect() makes that explicit.
|
|
||||||
write!(
|
|
||||||
conf,
|
|
||||||
"\n[{name}]\n\
|
"\n[{name}]\n\
|
||||||
path = {}\n\
|
path = {}\n\
|
||||||
comment = PXEForge Windows install media ({name})\n\
|
comment = PXEForge Windows install media ({name})\n\
|
||||||
@@ -107,8 +103,7 @@ impl SmbManager {
|
|||||||
browseable = yes\n\
|
browseable = yes\n\
|
||||||
available = yes\n",
|
available = yes\n",
|
||||||
path.display(),
|
path.display(),
|
||||||
)
|
));
|
||||||
.expect("writing to a String is infallible");
|
|
||||||
}
|
}
|
||||||
let tmp = self.conf_path.with_extension("conf.tmp");
|
let tmp = self.conf_path.with_extension("conf.tmp");
|
||||||
std::fs::write(&tmp, conf)?;
|
std::fs::write(&tmp, conf)?;
|
||||||
@@ -119,7 +114,7 @@ impl SmbManager {
|
|||||||
/// Start smbd. No-op if already running.
|
/// Start smbd. No-op if already running.
|
||||||
pub fn start(&self) -> SmbState {
|
pub fn start(&self) -> SmbState {
|
||||||
let mut g = self.child.lock();
|
let mut g = self.child.lock();
|
||||||
if g.as_ref().is_some_and(|c| c.id() > 0) {
|
if g.as_ref().map_or(false, |c| c.id() > 0) {
|
||||||
return self.state.lock().clone();
|
return self.state.lock().clone();
|
||||||
}
|
}
|
||||||
if !smbd_present() {
|
if !smbd_present() {
|
||||||
@@ -178,10 +173,7 @@ impl SmbManager {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Some(c) = g.as_mut() {
|
if let Some(c) = g.as_mut() {
|
||||||
// u32 -> i32 for libc::kill. We never spawn enough children
|
let pid = c.id() as i32;
|
||||||
// for the pid to overflow i32; cast_signed makes the intent
|
|
||||||
// explicit and silences the lint.
|
|
||||||
let pid = c.id().cast_signed();
|
|
||||||
// SAFETY: libc::kill is FFI-safe; we pass a pid we own (returned
|
// SAFETY: libc::kill is FFI-safe; we pass a pid we own (returned
|
||||||
// from `Child::id` above, the child is alive because we hold the
|
// from `Child::id` above, the child is alive because we hold the
|
||||||
// Mutex guard `g`) and a well-defined signal constant. Return
|
// Mutex guard `g`) and a well-defined signal constant. Return
|
||||||
@@ -219,7 +211,7 @@ fn smbd_present() -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
const SMB_CONF_GLOBAL: &str = r"[global]
|
const SMB_CONF_GLOBAL: &str = r#"[global]
|
||||||
workgroup = PXEFORGE
|
workgroup = PXEFORGE
|
||||||
server min protocol = SMB2
|
server min protocol = SMB2
|
||||||
smb ports = 445
|
smb ports = 445
|
||||||
@@ -235,7 +227,7 @@ lock directory = /tmp
|
|||||||
state directory = /tmp
|
state directory = /tmp
|
||||||
cache directory = /tmp
|
cache directory = /tmp
|
||||||
pid directory = /tmp
|
pid directory = /tmp
|
||||||
";
|
"#;
|
||||||
|
|
||||||
/// Extract a Windows ISO at `iso_path` into `smb_dir/<slug>/`. Uses
|
/// Extract a Windows ISO at `iso_path` into `smb_dir/<slug>/`. Uses
|
||||||
/// `7z` when available (most reliable for UDF + ISO9660 hybrid images);
|
/// `7z` when available (most reliable for UDF + ISO9660 hybrid images);
|
||||||
@@ -279,10 +271,10 @@ pub fn extract_windows_iso(iso_path: &Path, smb_dir: &Path, slug: &str) -> std::
|
|||||||
.arg(&target)
|
.arg(&target)
|
||||||
.output()?;
|
.output()?;
|
||||||
if out.status.success() { return Ok(target); }
|
if out.status.success() { return Ok(target); }
|
||||||
return Err(std::io::Error::other(format!(
|
return Err(std::io::Error::new(
|
||||||
"bsdtar failed: {}",
|
std::io::ErrorKind::Other,
|
||||||
String::from_utf8_lossy(&out.stderr)
|
format!("bsdtar failed: {}", String::from_utf8_lossy(&out.stderr)),
|
||||||
)));
|
));
|
||||||
}
|
}
|
||||||
Err(std::io::Error::new(
|
Err(std::io::Error::new(
|
||||||
std::io::ErrorKind::NotFound,
|
std::io::ErrorKind::NotFound,
|
||||||
|
|||||||
@@ -19,10 +19,9 @@ use tokio::io::AsyncWriteExt;
|
|||||||
/// `Nfs` entries point at a file inside a remote share that the
|
/// `Nfs` entries point at a file inside a remote share that the
|
||||||
/// `NfsManager` is keeping mounted. We resolve the on-disk path lazily
|
/// `NfsManager` is keeping mounted. We resolve the on-disk path lazily
|
||||||
/// in [`IsoStore::iso_path_for`] using the `nfs_root` set at startup.
|
/// in [`IsoStore::iso_path_for`] using the `nfs_root` set at startup.
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum IsoSource {
|
pub enum IsoSource {
|
||||||
#[default]
|
|
||||||
Local,
|
Local,
|
||||||
Nfs {
|
Nfs {
|
||||||
mount_id: String,
|
mount_id: String,
|
||||||
@@ -31,6 +30,12 @@ pub enum IsoSource {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for IsoSource {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Local
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct IsoMeta {
|
pub struct IsoMeta {
|
||||||
/// Stable slug used in URLs (derived from the uploaded filename).
|
/// Stable slug used in URLs (derived from the uploaded filename).
|
||||||
@@ -151,11 +156,7 @@ impl IsoStore {
|
|||||||
while let Some(e) = entries.next_entry().await? {
|
while let Some(e) = entries.next_entry().await? {
|
||||||
let p = e.path();
|
let p = e.path();
|
||||||
if p.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
|
if p.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
|
||||||
if !p
|
if !p.file_name().and_then(|s| s.to_str()).map_or(false, |n| n.ends_with(".meta.json")) {
|
||||||
.file_name()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.is_some_and(|n| n.ends_with(".meta.json"))
|
|
||||||
{
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Ok(text) = tokio::fs::read_to_string(&p).await {
|
if let Ok(text) = tokio::fs::read_to_string(&p).await {
|
||||||
@@ -217,8 +218,7 @@ impl IsoStore {
|
|||||||
pub fn list(&self) -> Vec<IsoMeta> {
|
pub fn list(&self) -> Vec<IsoMeta> {
|
||||||
let g = self.inner.read();
|
let g = self.inner.read();
|
||||||
let mut v: Vec<_> = g.isos.values().cloned().collect();
|
let mut v: Vec<_> = g.isos.values().cloned().collect();
|
||||||
// Newest-first by upload time.
|
v.sort_by(|a, b| b.uploaded_at.cmp(&a.uploaded_at));
|
||||||
v.sort_by_key(|m| std::cmp::Reverse(m.uploaded_at));
|
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,9 +403,9 @@ fn linux_cmdline(family: DistroFamily, id: &str) -> String {
|
|||||||
DistroFamily::OpenSuse => format!(
|
DistroFamily::OpenSuse => format!(
|
||||||
"install={iso_url} netsetup=dhcp"
|
"install={iso_url} netsetup=dhcp"
|
||||||
),
|
),
|
||||||
DistroFamily::Arch => {
|
DistroFamily::Arch => format!(
|
||||||
"archiso_http_srv=${base-url}/iso/ archisobasedir=arch ip=dhcp copytoram".to_string()
|
"archiso_http_srv=${{base-url}}/iso/ archisobasedir=arch ip=dhcp copytoram"
|
||||||
}
|
),
|
||||||
DistroFamily::Alpine => format!(
|
DistroFamily::Alpine => format!(
|
||||||
"alpine_repo=${{base-url}}/iso/{id}/ modloop=${{base-url}}/iso/{id}/boot/modloop-lts ip=dhcp"
|
"alpine_repo=${{base-url}}/iso/{id}/ modloop=${{base-url}}/iso/{id}/boot/modloop-lts ip=dhcp"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -152,35 +152,18 @@ const WINPESHL_INI: &str = "[LaunchApps]\r\n\
|
|||||||
/// Uses CRLF line endings because WinPE cmd.exe requires them for .cmd files
|
/// Uses CRLF line endings because WinPE cmd.exe requires them for .cmd files
|
||||||
/// created on unix hosts.
|
/// created on unix hosts.
|
||||||
fn render_startnet(host: &str, share: &str) -> String {
|
fn render_startnet(host: &str, share: &str) -> String {
|
||||||
use std::fmt::Write as _;
|
let mut s = String::new();
|
||||||
let host = host.trim();
|
let host = host.trim();
|
||||||
let share = share.trim_matches('/');
|
let share = share.trim_matches('/');
|
||||||
let mut s = String::new();
|
|
||||||
// Windows-style CRLF; consumed verbatim by cmd.exe inside WinPE.
|
|
||||||
// Bootimus v0.1.58 lesson: surface `net use` errors instead of
|
|
||||||
// tight-looping on a blind retry. We retry but log every miss.
|
|
||||||
s.push_str("@echo off\r\n");
|
s.push_str("@echo off\r\n");
|
||||||
s.push_str("echo PXEForge WinPE bootstrap\r\n");
|
s.push_str("echo PXEForge WinPE bootstrap\r\n");
|
||||||
s.push_str("wpeinit\r\n");
|
s.push_str("wpeinit\r\n");
|
||||||
// v0.1.58: explicitly start Workstation before mapping the share —
|
|
||||||
// `net use` otherwise lazily inits SMB-client and races wpeinit.
|
|
||||||
s.push_str("net start Workstation >nul 2>&1\r\n");
|
|
||||||
s.push_str("ipconfig /renew\r\n");
|
s.push_str("ipconfig /renew\r\n");
|
||||||
writeln!(s, "echo Waiting for SMB server {host} to be reachable...\r").unwrap();
|
s.push_str(&format!("echo Waiting for SMB server {host} to be reachable...\r\n"));
|
||||||
writeln!(
|
s.push_str(&format!(":waitsmb\r\nping -n 1 -w 500 {host} >nul && goto havenet\r\ntimeout /t 2 /nobreak >nul\r\ngoto waitsmb\r\n"));
|
||||||
s,
|
|
||||||
":waitsmb\r\nping -n 1 -w 500 {host} >nul && goto havenet\r\n\
|
|
||||||
timeout /t 2 /nobreak >nul\r\ngoto waitsmb\r"
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
s.push_str(":havenet\r\n");
|
s.push_str(":havenet\r\n");
|
||||||
writeln!(s, "echo Mapping install media from \\\\{host}\\{share}...\r").unwrap();
|
s.push_str(&format!("echo Mapping install media from \\\\{host}\\{share}...\r\n"));
|
||||||
writeln!(
|
s.push_str(&format!(":mapshare\r\nnet use Z: \\\\{host}\\{share} /user:guest \"\" /persistent:no && goto mapped\r\ntimeout /t 3 /nobreak >nul\r\ngoto mapshare\r\n"));
|
||||||
s,
|
|
||||||
":mapshare\r\nnet use Z: \\\\{host}\\{share} /user:guest \"\" /persistent:no && goto mapped\r\n\
|
|
||||||
timeout /t 3 /nobreak >nul\r\ngoto mapshare\r"
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
s.push_str(":mapped\r\n");
|
s.push_str(":mapped\r\n");
|
||||||
s.push_str("echo Starting Windows Setup\r\n");
|
s.push_str("echo Starting Windows Setup\r\n");
|
||||||
s.push_str("Z:\\setup.exe\r\n");
|
s.push_str("Z:\\setup.exe\r\n");
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
//! them concurrently.
|
//! them concurrently.
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use pxeforge_core::{
|
use pxeforge_core::{ClientRegistry, Config, DhcpMode, GateQueue, LogBus, LogBusLayer, SettingsStore};
|
||||||
ClientRegistry, Config, DhcpMode, GateQueue, HostBindings, LogBus, LogBusLayer, Metrics,
|
|
||||||
SettingsStore,
|
|
||||||
};
|
|
||||||
use pxeforge_dhcp_proxy::DhcpProxyServer;
|
use pxeforge_dhcp_proxy::DhcpProxyServer;
|
||||||
use pxeforge_http_api::{build_router, AppState};
|
use pxeforge_http_api::{build_router, AppState};
|
||||||
use pxeforge_iso_store::{IsoStore, NfsManager, SmbManager};
|
use pxeforge_iso_store::{IsoStore, NfsManager, SmbManager};
|
||||||
@@ -102,8 +99,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let clients = ClientRegistry::new();
|
let clients = ClientRegistry::new();
|
||||||
let gates = GateQueue::new();
|
let gates = GateQueue::new();
|
||||||
let settings = SettingsStore::load_or_default(&config.paths.work_dir);
|
let settings = SettingsStore::load_or_default(&config.paths.work_dir);
|
||||||
let hosts = HostBindings::load_or_default(&config.paths.work_dir);
|
|
||||||
let metrics = Metrics::new();
|
|
||||||
|
|
||||||
// Build the SMB manager unconditionally — it starts/stops on the
|
// Build the SMB manager unconditionally — it starts/stops on the
|
||||||
// Windows toggle, not at process start. If the `smb_dir` isn't
|
// Windows toggle, not at process start. If the `smb_dir` isn't
|
||||||
@@ -139,8 +134,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
clients: clients.clone(),
|
clients: clients.clone(),
|
||||||
settings: settings.clone(),
|
settings: settings.clone(),
|
||||||
gates: gates.clone(),
|
gates: gates.clone(),
|
||||||
hosts: hosts.clone(),
|
|
||||||
metrics: metrics.clone(),
|
|
||||||
smb: Some(smb.clone()),
|
smb: Some(smb.clone()),
|
||||||
nfs: nfs.clone(),
|
nfs: nfs.clone(),
|
||||||
log_bus: log_bus.clone(),
|
log_bus: log_bus.clone(),
|
||||||
@@ -160,12 +153,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Ok::<_, anyhow::Error>(())
|
Ok::<_, anyhow::Error>(())
|
||||||
});
|
});
|
||||||
|
|
||||||
let tftp = TftpServer::new(
|
let tftp = TftpServer::new(config.server.tftp_bind, config.server.tftp_port, clients.clone());
|
||||||
config.server.tftp_bind,
|
|
||||||
config.server.tftp_port,
|
|
||||||
clients.clone(),
|
|
||||||
metrics.clone(),
|
|
||||||
);
|
|
||||||
let tftp_task = tokio::spawn(tftp.run());
|
let tftp_task = tokio::spawn(tftp.run());
|
||||||
|
|
||||||
let dhcp_task: tokio::task::JoinHandle<anyhow::Result<()>> = match config.network.dhcp_mode {
|
let dhcp_task: tokio::task::JoinHandle<anyhow::Result<()>> = match config.network.dhcp_mode {
|
||||||
@@ -177,7 +165,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
our_ip,
|
our_ip,
|
||||||
public_base_url.clone(),
|
public_base_url.clone(),
|
||||||
clients.clone(),
|
clients.clone(),
|
||||||
metrics.clone(),
|
|
||||||
);
|
);
|
||||||
tokio::spawn(s.run())
|
tokio::spawn(s.run())
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-29
@@ -35,24 +35,17 @@ pub struct TftpServer {
|
|||||||
bind: IpAddr,
|
bind: IpAddr,
|
||||||
port: u16,
|
port: u16,
|
||||||
clients: Arc<ClientRegistry>,
|
clients: Arc<ClientRegistry>,
|
||||||
metrics: pxeforge_core::Metrics,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TftpServer {
|
impl TftpServer {
|
||||||
pub fn new(
|
pub fn new(bind: IpAddr, port: u16, clients: Arc<ClientRegistry>) -> Self {
|
||||||
bind: IpAddr,
|
Self { bind, port, clients }
|
||||||
port: u16,
|
|
||||||
clients: Arc<ClientRegistry>,
|
|
||||||
metrics: pxeforge_core::Metrics,
|
|
||||||
) -> Self {
|
|
||||||
Self { bind, port, clients, metrics }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run(self) -> anyhow::Result<()> {
|
pub async fn run(self) -> anyhow::Result<()> {
|
||||||
let sock = bind_udp(self.bind, self.port)?;
|
let sock = bind_udp(self.bind, self.port)?;
|
||||||
tracing::info!(target: "pxeforge::tftp", "TFTP listening on {}:{}", self.bind, self.port);
|
tracing::info!(target: "pxeforge::tftp", "TFTP listening on {}:{}", self.bind, self.port);
|
||||||
let clients = self.clients.clone();
|
let clients = self.clients.clone();
|
||||||
let metrics = self.metrics.clone();
|
|
||||||
let mut buf = vec![0u8; 2048];
|
let mut buf = vec![0u8; 2048];
|
||||||
loop {
|
loop {
|
||||||
let (n, from) = match sock.recv_from(&mut buf).await {
|
let (n, from) = match sock.recv_from(&mut buf).await {
|
||||||
@@ -64,11 +57,9 @@ impl TftpServer {
|
|||||||
};
|
};
|
||||||
let data = buf[..n].to_vec();
|
let data = buf[..n].to_vec();
|
||||||
let clients = clients.clone();
|
let clients = clients.clone();
|
||||||
let metrics = metrics.clone();
|
|
||||||
let bind_ip = self.bind;
|
let bind_ip = self.bind;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = handle_rrq(data, from, bind_ip, clients, metrics.clone()).await {
|
if let Err(e) = handle_rrq(data, from, bind_ip, clients).await {
|
||||||
metrics.record_tftp_err();
|
|
||||||
tracing::warn!(target: "pxeforge::tftp", peer=%from, "handler error: {e}");
|
tracing::warn!(target: "pxeforge::tftp", peer=%from, "handler error: {e}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -81,10 +72,10 @@ async fn handle_rrq(
|
|||||||
peer: SocketAddr,
|
peer: SocketAddr,
|
||||||
bind_ip: IpAddr,
|
bind_ip: IpAddr,
|
||||||
clients: Arc<ClientRegistry>,
|
clients: Arc<ClientRegistry>,
|
||||||
metrics: pxeforge_core::Metrics,
|
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let Some(req) = parse_rrq(&packet) else {
|
let req = match parse_rrq(&packet) {
|
||||||
return Ok(());
|
Some(r) => r,
|
||||||
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
let Request { filename, options, .. } = req;
|
let Request { filename, options, .. } = req;
|
||||||
|
|
||||||
@@ -158,11 +149,7 @@ async fn handle_rrq(
|
|||||||
let total = file_bytes.len();
|
let total = file_bytes.len();
|
||||||
let mut offset: usize = 0;
|
let mut offset: usize = 0;
|
||||||
let mut block_no: u16 = 1;
|
let mut block_no: u16 = 1;
|
||||||
// Per RFC 1350: if the final data block is exactly blksize, the
|
let mut needs_zero_final = false; // spec: if last data block == blksize, follow with empty DATA
|
||||||
// server must follow up with a zero-length DATA so the client knows
|
|
||||||
// the transfer has ended. The flag is set inside the loop and
|
|
||||||
// tested at end-of-transfer.
|
|
||||||
let needs_zero_final;
|
|
||||||
|
|
||||||
'transfer: loop {
|
'transfer: loop {
|
||||||
let window_start_offset = offset;
|
let window_start_offset = offset;
|
||||||
@@ -194,7 +181,7 @@ async fn handle_rrq(
|
|||||||
loop {
|
loop {
|
||||||
match tokio::time::timeout(Duration::from_secs(3), recv_ack(&sock, peer)).await {
|
match tokio::time::timeout(Duration::from_secs(3), recv_ack(&sock, peer)).await {
|
||||||
Ok(Ok(acked)) if acked == last_block_in_window => break,
|
Ok(Ok(acked)) if acked == last_block_in_window => break,
|
||||||
Ok(Ok(_)) => {} // stale ACK from an earlier block — ignore
|
Ok(Ok(_)) => continue, // stale ACK from an earlier block — ignore
|
||||||
Ok(Err(e)) => return Err(e),
|
Ok(Err(e)) => return Err(e),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
tries += 1;
|
tries += 1;
|
||||||
@@ -242,7 +229,6 @@ async fn handle_rrq(
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracing::debug!(target: "pxeforge::tftp", peer=%peer, bytes=total, "transfer complete");
|
tracing::debug!(target: "pxeforge::tftp", peer=%peer, bytes=total, "transfer complete");
|
||||||
metrics.record_tftp_ok(total as u64);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +249,7 @@ fn parse_rrq(pkt: &[u8]) -> Option<Request> {
|
|||||||
let mode = read_cstr(&mut rest)?;
|
let mode = read_cstr(&mut rest)?;
|
||||||
let mut options = Vec::new();
|
let mut options = Vec::new();
|
||||||
while !rest.is_empty() {
|
while !rest.is_empty() {
|
||||||
let Some(k) = read_cstr(&mut rest) else { break };
|
let k = match read_cstr(&mut rest) { Some(s) => s, None => break };
|
||||||
if k.is_empty() { break; }
|
if k.is_empty() { break; }
|
||||||
let v = read_cstr(&mut rest).unwrap_or_default();
|
let v = read_cstr(&mut rest).unwrap_or_default();
|
||||||
options.push((k.to_ascii_lowercase(), v));
|
options.push((k.to_ascii_lowercase(), v));
|
||||||
@@ -325,7 +311,7 @@ async fn recv_ack(sock: &UdpSocket, peer: SocketAddr) -> anyhow::Result<u16> {
|
|||||||
let code = u16::from_be_bytes([buf[2], buf[3]]);
|
let code = u16::from_be_bytes([buf[2], buf[3]]);
|
||||||
anyhow::bail!("client error {code}");
|
anyhow::bail!("client error {code}");
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => continue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,7 +327,7 @@ async fn wait_for_ack(
|
|||||||
sock.send_to(to_retx, peer).await?;
|
sock.send_to(to_retx, peer).await?;
|
||||||
match tokio::time::timeout(Duration::from_secs(3), recv_ack(sock, peer)).await {
|
match tokio::time::timeout(Duration::from_secs(3), recv_ack(sock, peer)).await {
|
||||||
Ok(Ok(b)) if b == expect_block => return Ok(true),
|
Ok(Ok(b)) if b == expect_block => return Ok(true),
|
||||||
Ok(Ok(_)) => {}
|
Ok(Ok(_)) => continue,
|
||||||
Ok(Err(_)) | Err(_) => {
|
Ok(Err(_)) | Err(_) => {
|
||||||
tries += 1;
|
tries += 1;
|
||||||
if tries > 5 { return Ok(false); }
|
if tries > 5 { return Ok(false); }
|
||||||
@@ -399,10 +385,7 @@ mod tests {
|
|||||||
fn parses_rrq_with_options() {
|
fn parses_rrq_with_options() {
|
||||||
// RRQ "snponly.efi" mode "octet" blksize=1468 tsize=0
|
// RRQ "snponly.efi" mode "octet" blksize=1468 tsize=0
|
||||||
let mut pkt = vec![0, OP_RRQ as u8];
|
let mut pkt = vec![0, OP_RRQ as u8];
|
||||||
// The single-digit `\0` escapes here are NUL terminators between
|
pkt.extend_from_slice(b"snponly.efi\0octet\0blksize\01468\0tsize\00\0");
|
||||||
// TFTP option name/value pairs — using `\x00` to dodge clippy's
|
|
||||||
// "octal-looking escape" lint.
|
|
||||||
pkt.extend_from_slice(b"snponly.efi\x00octet\x00blksize\x001468\x00tsize\x000\x00");
|
|
||||||
let r = parse_rrq(&pkt).unwrap();
|
let r = parse_rrq(&pkt).unwrap();
|
||||||
assert_eq!(r.filename, "snponly.efi");
|
assert_eq!(r.filename, "snponly.efi");
|
||||||
assert_eq!(r.mode, "octet");
|
assert_eq!(r.mode, "octet");
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" fill="none">
|
|
||||||
<title>PXEForge — forging</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="afBody" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#aab3c2"/>
|
|
||||||
<stop offset="55%" stop-color="#7d8696"/>
|
|
||||||
<stop offset="100%" stop-color="#525a6b"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="afFace" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#cdd5e1"/>
|
|
||||||
<stop offset="100%" stop-color="#9aa3b3"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="afBase" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#3b4252"/>
|
|
||||||
<stop offset="100%" stop-color="#252a36"/>
|
|
||||||
</linearGradient>
|
|
||||||
<radialGradient id="afSpark" cx="50%" cy="50%" r="50%">
|
|
||||||
<stop offset="0%" stop-color="#fff5b8" stop-opacity="1"/>
|
|
||||||
<stop offset="40%" stop-color="#ff9a3a" stop-opacity="0.9"/>
|
|
||||||
<stop offset="100%" stop-color="#ff5a18" stop-opacity="0"/>
|
|
||||||
</radialGradient>
|
|
||||||
<radialGradient id="afEmber" cx="50%" cy="50%" r="50%">
|
|
||||||
<stop offset="0%" stop-color="#ffd47a" stop-opacity="1"/>
|
|
||||||
<stop offset="100%" stop-color="#ff7322" stop-opacity="0"/>
|
|
||||||
</radialGradient>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<!-- Anvil shifted down so sparks have room to rise above -->
|
|
||||||
<g transform="translate(0,40)">
|
|
||||||
<!-- Horn + face -->
|
|
||||||
<path d="M14 36 L72 30 L162 30 L162 46 L72 46 Z"
|
|
||||||
fill="url(#afFace)" stroke="#1d2330" stroke-width="2.4" stroke-linejoin="round"/>
|
|
||||||
<!-- Body / waist -->
|
|
||||||
<path d="M70 46 L160 46 L142 70 L88 70 Z"
|
|
||||||
fill="url(#afBody)" stroke="#1d2330" stroke-width="2.4" stroke-linejoin="round"/>
|
|
||||||
<!-- Pillar -->
|
|
||||||
<rect x="92" y="70" width="46" height="26" fill="url(#afBody)"
|
|
||||||
stroke="#1d2330" stroke-width="2.4"/>
|
|
||||||
<!-- Base -->
|
|
||||||
<path d="M62 96 L168 96 L160 110 L70 110 Z"
|
|
||||||
fill="url(#afBase)" stroke="#0d1018" stroke-width="2.4" stroke-linejoin="round"/>
|
|
||||||
<line x1="74" y1="34" x2="158" y2="34" stroke="#e6ecf5" stroke-width="1.2" opacity="0.7"/>
|
|
||||||
<!-- Soft underglow on top face where the sparks land -->
|
|
||||||
<ellipse cx="115" cy="33" rx="42" ry="6" fill="url(#afEmber)" opacity="0.55">
|
|
||||||
<animate attributeName="opacity" values="0.35;0.7;0.35"
|
|
||||||
dur="1.6s" repeatCount="indefinite"/>
|
|
||||||
</ellipse>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Sparks. SMIL animations only — no JS, no CSS needed. Each spark
|
|
||||||
rises, fades, restarts at a staggered delay for an organic feel. -->
|
|
||||||
<g class="sparks">
|
|
||||||
<circle cx="116" cy="62" r="3.4" fill="url(#afSpark)" opacity="0">
|
|
||||||
<animate attributeName="cy" from="62" to="14" dur="1.4s" repeatCount="indefinite"/>
|
|
||||||
<animate attributeName="cx" values="116;112;120;116" dur="1.4s" repeatCount="indefinite"/>
|
|
||||||
<animate attributeName="r" values="2;3.6;1.4" dur="1.4s" repeatCount="indefinite"/>
|
|
||||||
<animate attributeName="opacity" values="0;1;0" dur="1.4s" repeatCount="indefinite"/>
|
|
||||||
</circle>
|
|
||||||
<circle cx="105" cy="62" r="2.4" fill="url(#afSpark)" opacity="0">
|
|
||||||
<animate attributeName="cy" from="62" to="22" dur="1.7s" begin="0.25s" repeatCount="indefinite"/>
|
|
||||||
<animate attributeName="cx" values="105;101;108;104" dur="1.7s" begin="0.25s" repeatCount="indefinite"/>
|
|
||||||
<animate attributeName="r" values="1.6;2.8;1" dur="1.7s" begin="0.25s" repeatCount="indefinite"/>
|
|
||||||
<animate attributeName="opacity" values="0;1;0" dur="1.7s" begin="0.25s" repeatCount="indefinite"/>
|
|
||||||
</circle>
|
|
||||||
<circle cx="125" cy="62" r="2.8" fill="url(#afSpark)" opacity="0">
|
|
||||||
<animate attributeName="cy" from="62" to="6" dur="1.9s" begin="0.55s" repeatCount="indefinite"/>
|
|
||||||
<animate attributeName="cx" values="125;130;121;127" dur="1.9s" begin="0.55s" repeatCount="indefinite"/>
|
|
||||||
<animate attributeName="r" values="1.8;3.2;1.2" dur="1.9s" begin="0.55s" repeatCount="indefinite"/>
|
|
||||||
<animate attributeName="opacity" values="0;1;0" dur="1.9s" begin="0.55s" repeatCount="indefinite"/>
|
|
||||||
</circle>
|
|
||||||
<circle cx="113" cy="62" r="2" fill="url(#afEmber)" opacity="0">
|
|
||||||
<animate attributeName="cy" from="62" to="32" dur="1.2s" begin="0.9s" repeatCount="indefinite"/>
|
|
||||||
<animate attributeName="opacity" values="0;0.9;0" dur="1.2s" begin="0.9s" repeatCount="indefinite"/>
|
|
||||||
</circle>
|
|
||||||
<circle cx="132" cy="62" r="2.2" fill="url(#afSpark)" opacity="0">
|
|
||||||
<animate attributeName="cy" from="62" to="20" dur="1.5s" begin="1.2s" repeatCount="indefinite"/>
|
|
||||||
<animate attributeName="cx" values="132;138;128" dur="1.5s" begin="1.2s" repeatCount="indefinite"/>
|
|
||||||
<animate attributeName="opacity" values="0;1;0" dur="1.5s" begin="1.2s" repeatCount="indefinite"/>
|
|
||||||
</circle>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.6 KiB |
+100
-194
@@ -1,66 +1,35 @@
|
|||||||
/* PXEForge web UI — Netbox-style minimal layout, fully offline.
|
/* PXEForge web UI — Netbox-style layout, fully offline.
|
||||||
*
|
* Design tokens are CSS variables so a later phase can re-theme without
|
||||||
* Theme tokens live on `:root` (dark default) and `:root[data-theme=light]`.
|
* touching markup or JS. */
|
||||||
* Both palettes share variable *names*, so component CSS uses
|
|
||||||
* `var(--bg)` regardless and the toggle in the topbar just flips the
|
|
||||||
* data-attribute. No JS-side recolouring, no React re-renders, no FOUC
|
|
||||||
* (the inline script in index.html paints the right theme before main
|
|
||||||
* CSS lands). */
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Dark palette (default). */
|
--bg: #0b1018;
|
||||||
--bg: #0b1018;
|
--bg-panel: #121826;
|
||||||
--bg-panel: #121826;
|
--bg-panel-2: #1a2334;
|
||||||
--bg-panel-2: #1a2334;
|
--bg-elev: #223047;
|
||||||
--bg-elev: #223047;
|
--fg: #e4e8ef;
|
||||||
--fg: #e4e8ef;
|
--fg-dim: #8a94a7;
|
||||||
--fg-dim: #8a94a7;
|
--fg-dimmer: #5a6379;
|
||||||
--fg-dimmer: #5a6379;
|
--accent: #00d4b4; /* Netbox-ish teal */
|
||||||
--accent: #00d4b4; /* Netbox-ish teal */
|
--accent-dim: #07a38c;
|
||||||
--accent-dim: #07a38c;
|
--warn: #ffb347;
|
||||||
--warn: #ffb347;
|
--err: #ef6e6e;
|
||||||
--err: #ef6e6e;
|
--ok: #4ade80;
|
||||||
--ok: #4ade80;
|
--border: #223047;
|
||||||
--border: #223047;
|
|
||||||
--border-soft: #172033;
|
--border-soft: #172033;
|
||||||
--terminal-bg: #06090e;
|
--radius: 6px;
|
||||||
--shadow-card: 0 1px 0 rgba(255,255,255,0.02), 0 8px 24px rgba(0,0,0,0.25);
|
|
||||||
--radius: 6px;
|
|
||||||
--radius-lg: 10px;
|
--radius-lg: 10px;
|
||||||
--sidebar-w: 240px;
|
--sidebar-w: 240px;
|
||||||
--topbar-h: 56px;
|
--topbar-h: 54px;
|
||||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
|
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] {
|
|
||||||
/* Light palette — high-contrast neutral, accent unchanged for brand
|
|
||||||
consistency. Designed against Netbox Labs's reference screenshot:
|
|
||||||
near-white surfaces, soft grey dividers, dark text. */
|
|
||||||
--bg: #f6f8fb;
|
|
||||||
--bg-panel: #ffffff;
|
|
||||||
--bg-panel-2: #f0f3f8;
|
|
||||||
--bg-elev: #e6ebf2;
|
|
||||||
--fg: #1c2330;
|
|
||||||
--fg-dim: #5a6377;
|
|
||||||
--fg-dimmer: #95a0b3;
|
|
||||||
--accent: #00b89c;
|
|
||||||
--accent-dim: #008b73;
|
|
||||||
--warn: #b67016;
|
|
||||||
--err: #c63a3a;
|
|
||||||
--ok: #1f9b54;
|
|
||||||
--border: #d8dde6;
|
|
||||||
--border-soft: #e7eaf0;
|
|
||||||
--terminal-bg: #0d1219; /* terminal stays dark even in light mode */
|
|
||||||
--shadow-card: 0 1px 0 rgba(0,0,0,0.02), 0 6px 18px rgba(20,28,52,0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html, body { height: 100%; }
|
html, body { height: 100%; }
|
||||||
body {
|
body {
|
||||||
margin: 0; font-family: var(--sans); font-size: 14px; line-height: 1.5;
|
margin: 0; font-family: var(--sans); font-size: 14px; line-height: 1.5;
|
||||||
background: var(--bg); color: var(--fg);
|
background: var(--bg); color: var(--fg);
|
||||||
transition: background 0.16s ease, color 0.16s ease;
|
|
||||||
}
|
}
|
||||||
a { color: var(--accent); text-decoration: none; }
|
a { color: var(--accent); text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
@@ -93,11 +62,15 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
|
|||||||
.sidebar .brand strong { font-size: 16px; letter-spacing: 0.4px; }
|
.sidebar .brand strong { font-size: 16px; letter-spacing: 0.4px; }
|
||||||
.sidebar .brand .sub { color: var(--fg-dim); font-size: 11px; }
|
.sidebar .brand .sub { color: var(--fg-dim); font-size: 11px; }
|
||||||
.sidebar nav { padding: 10px 0; flex: 1; overflow-y: auto; }
|
.sidebar nav { padding: 10px 0; flex: 1; overflow-y: auto; }
|
||||||
|
.sidebar nav .group {
|
||||||
|
padding: 10px 18px 6px;
|
||||||
|
font-size: 10.5px; color: var(--fg-dimmer); text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
.sidebar nav a {
|
.sidebar nav a {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
padding: 8px 18px; color: var(--fg); font-size: 13.5px;
|
padding: 7px 18px; color: var(--fg); font-size: 13.5px;
|
||||||
border-left: 2px solid transparent;
|
border-left: 2px solid transparent;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
.sidebar nav a:hover { background: var(--bg-panel-2); text-decoration: none; }
|
.sidebar nav a:hover { background: var(--bg-panel-2); text-decoration: none; }
|
||||||
.sidebar nav a.active {
|
.sidebar nav a.active {
|
||||||
@@ -123,7 +96,7 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
|
|||||||
.topbar {
|
.topbar {
|
||||||
grid-area: topbar;
|
grid-area: topbar;
|
||||||
display: flex; align-items: center;
|
display: flex; align-items: center;
|
||||||
padding: 0 20px; gap: 14px;
|
padding: 0 20px; gap: 18px;
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
@@ -131,31 +104,23 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
|
|||||||
margin: 0; font-size: 15px; font-weight: 600;
|
margin: 0; font-size: 15px; font-weight: 600;
|
||||||
color: var(--fg); letter-spacing: 0.2px;
|
color: var(--fg); letter-spacing: 0.2px;
|
||||||
}
|
}
|
||||||
|
.topbar .tabs { display: flex; gap: 4px; margin-left: 24px; }
|
||||||
|
.topbar .tabs button {
|
||||||
|
background: transparent; border: 0;
|
||||||
|
color: var(--fg-dim); font: inherit;
|
||||||
|
padding: 10px 14px; cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.topbar .tabs button:hover { color: var(--fg); }
|
||||||
|
.topbar .tabs button.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||||
.topbar .spacer { flex: 1; }
|
.topbar .spacer { flex: 1; }
|
||||||
.topbar .chip {
|
.topbar .chip {
|
||||||
background: var(--bg-panel-2); border: 1px solid var(--border);
|
background: var(--bg-panel-2); border: 1px solid var(--border);
|
||||||
color: var(--fg-dim); font-size: 12px;
|
color: var(--fg-dim); font-size: 12px;
|
||||||
padding: 4px 10px; border-radius: 12px;
|
padding: 4px 10px; border-radius: 12px;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
.topbar .chip strong { color: var(--fg); font-weight: 600; }
|
.topbar .chip strong { color: var(--fg); font-weight: 600; }
|
||||||
|
|
||||||
/* Theme toggle button. Two glyphs stacked; CSS swaps which is visible
|
|
||||||
based on the active theme. Keeps the layout stable when toggling. */
|
|
||||||
.theme-toggle {
|
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
|
||||||
width: 36px; height: 32px;
|
|
||||||
background: transparent; color: var(--fg);
|
|
||||||
border: 1px solid var(--border); border-radius: 8px;
|
|
||||||
cursor: pointer; padding: 0;
|
|
||||||
transition: background 0.15s ease, border-color 0.15s ease;
|
|
||||||
}
|
|
||||||
.theme-toggle:hover { background: var(--bg-panel-2); border-color: var(--accent); }
|
|
||||||
.theme-toggle .t-sun { display: none; }
|
|
||||||
.theme-toggle .t-moon { display: inline; }
|
|
||||||
:root[data-theme="light"] .theme-toggle .t-sun { display: inline; }
|
|
||||||
:root[data-theme="light"] .theme-toggle .t-moon { display: none; }
|
|
||||||
|
|
||||||
/* ── Main content ─────────────────────────────────────────────────── */
|
/* ── Main content ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
@@ -177,7 +142,6 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
}
|
}
|
||||||
.card > header {
|
.card > header {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
@@ -189,7 +153,9 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
|
|||||||
.card > header .sub { color: var(--fg-dim); font-size: 12px; margin-left: auto; }
|
.card > header .sub { color: var(--fg-dim); font-size: 12px; margin-left: auto; }
|
||||||
.card .body { padding: 16px; }
|
.card .body { padding: 16px; }
|
||||||
|
|
||||||
.stat { padding: 16px; }
|
.stat {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
.stat .label { color: var(--fg-dim); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.8px; }
|
.stat .label { color: var(--fg-dim); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.8px; }
|
||||||
.stat .value { font-size: 28px; font-weight: 600; line-height: 1.1; margin-top: 4px; color: var(--fg); }
|
.stat .value { font-size: 28px; font-weight: 600; line-height: 1.1; margin-top: 4px; color: var(--fg); }
|
||||||
.stat .trend { font-size: 12px; color: var(--fg-dim); margin-top: 4px; }
|
.stat .trend { font-size: 12px; color: var(--fg-dim); margin-top: 4px; }
|
||||||
@@ -213,12 +179,12 @@ td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 2px 8px; border-radius: 10px;
|
padding: 2px 8px; border-radius: 10px;
|
||||||
font-size: 11px; font-weight: 600;
|
font-size: 11px; font-weight: 600;
|
||||||
background: var(--bg-elev); color: var(--fg-dim);
|
background: #1b3148; color: #a2c5e8;
|
||||||
}
|
}
|
||||||
.tag.ok { background: color-mix(in srgb, var(--ok) 22%, transparent); color: var(--ok); }
|
.tag.ok { background: #103428; color: var(--ok); }
|
||||||
.tag.warn { background: color-mix(in srgb, var(--warn) 22%, transparent); color: var(--warn); }
|
.tag.warn { background: #3a2a10; color: var(--warn); }
|
||||||
.tag.err { background: color-mix(in srgb, var(--err) 22%, transparent); color: var(--err); }
|
.tag.err { background: #3a1515; color: var(--err); }
|
||||||
.tag.accent { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--accent); }
|
.tag.accent { background: #072f29; color: var(--accent); }
|
||||||
.tag.arch { text-transform: uppercase; }
|
.tag.arch { text-transform: uppercase; }
|
||||||
|
|
||||||
/* ── Forms ────────────────────────────────────────────────────────── */
|
/* ── Forms ────────────────────────────────────────────────────────── */
|
||||||
@@ -228,13 +194,12 @@ button, .btn {
|
|||||||
border: 0; border-radius: var(--radius);
|
border: 0; border-radius: var(--radius);
|
||||||
padding: 7px 14px; font: inherit; font-weight: 600;
|
padding: 7px 14px; font: inherit; font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.12s ease;
|
|
||||||
}
|
}
|
||||||
button:hover, .btn:hover { background: var(--accent-dim); color: #fff; }
|
button:hover, .btn:hover { background: var(--accent-dim); color: #fff; }
|
||||||
button.ghost { background: transparent; color: var(--fg); border: 1px solid var(--border); }
|
button.ghost { background: transparent; color: var(--fg); border: 1px solid var(--border); }
|
||||||
button.ghost:hover { background: var(--bg-panel-2); color: var(--fg); }
|
button.ghost:hover { background: var(--bg-panel-2); color: var(--fg); }
|
||||||
button.danger { background: transparent; color: var(--err); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); }
|
button.danger { background: transparent; color: var(--err); border: 1px solid #4a1f1f; }
|
||||||
button.danger:hover { background: color-mix(in srgb, var(--err) 14%, transparent); color: var(--err); }
|
button.danger:hover { background: #2a0b0b; color: var(--err); }
|
||||||
|
|
||||||
label.field {
|
label.field {
|
||||||
display: grid; gap: 4px; margin-bottom: 14px;
|
display: grid; gap: 4px; margin-bottom: 14px;
|
||||||
@@ -273,114 +238,12 @@ label.check input { accent-color: var(--accent); }
|
|||||||
background: var(--bg-panel-2);
|
background: var(--bg-panel-2);
|
||||||
}
|
}
|
||||||
.drop strong { color: var(--accent); }
|
.drop strong { color: var(--accent); }
|
||||||
|
|
||||||
/* Plain progress bar (used for ISO uploads). */
|
|
||||||
.progress { height: 6px; background: var(--bg-panel-2); border-radius: 3px; overflow: hidden; margin-top: 12px; display: none; }
|
.progress { height: 6px; background: var(--bg-panel-2); border-radius: 3px; overflow: hidden; margin-top: 12px; display: none; }
|
||||||
.progress.active { display: block; }
|
.progress.active { display: block; }
|
||||||
.progress .bar { height: 100%; width: 0%; background: var(--accent); transition: width .25s; }
|
.progress .bar { height: 100%; width: 0%; background: var(--accent); transition: width .25s; }
|
||||||
|
|
||||||
/* ── Forge progress widget ─────────────────────────────────────────
|
/* ── Gate queue "horse race" visual ───────────────────────────────── */
|
||||||
Anvil-with-sparks animation paired with a horizontal progress bar.
|
|
||||||
Used on the Forge Gate tab to give a sense of the "in-flight"
|
|
||||||
imaging count without having to read a number. */
|
|
||||||
.forge-progress {
|
|
||||||
display: flex; align-items: center; gap: 16px;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
.forge-progress .anvil {
|
|
||||||
width: 64px; height: 64px; flex: none;
|
|
||||||
background: url("/assets/anvil-forge.svg") no-repeat center / contain;
|
|
||||||
filter: drop-shadow(0 0 14px color-mix(in srgb, var(--warn) 40%, transparent));
|
|
||||||
}
|
|
||||||
.forge-progress .info { flex: 1; min-width: 0; }
|
|
||||||
.forge-progress .info .label {
|
|
||||||
font-size: 12.5px; color: var(--fg-dim); margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
.forge-progress .bar-track {
|
|
||||||
height: 8px; background: var(--bg-elev); border-radius: 4px;
|
|
||||||
overflow: hidden; position: relative;
|
|
||||||
}
|
|
||||||
.forge-progress .bar-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, var(--warn), var(--accent));
|
|
||||||
width: 0%;
|
|
||||||
transition: width 0.4s ease;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.forge-progress .bar-fill::after {
|
|
||||||
/* Subtle moving sheen so the bar feels alive even at 0% movement. */
|
|
||||||
content: ""; position: absolute; inset: 0;
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
rgba(255,255,255,0) 0%,
|
|
||||||
rgba(255,255,255,0.18) 50%,
|
|
||||||
rgba(255,255,255,0) 100%);
|
|
||||||
animation: forge-sheen 1.6s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes forge-sheen {
|
|
||||||
from { transform: translateX(-100%); }
|
|
||||||
to { transform: translateX(100%); }
|
|
||||||
}
|
|
||||||
.forge-progress.idle .anvil { filter: none; opacity: 0.45; }
|
|
||||||
.forge-progress.idle .bar-fill::after { animation: none; }
|
|
||||||
|
|
||||||
/* ── Page-load anvil ──────────────────────────────────────────────── */
|
|
||||||
.loader {
|
|
||||||
display: flex; flex-direction: column; align-items: center; gap: 12px;
|
|
||||||
padding: 40px 20px;
|
|
||||||
color: var(--fg-dim);
|
|
||||||
}
|
|
||||||
.loader .anvil {
|
|
||||||
width: 110px; height: 110px;
|
|
||||||
background: url("/assets/anvil-forge.svg") no-repeat center / contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Top bar readiness chip ──────────────────────────────────────── */
|
|
||||||
.chip.ready { background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok); border-color: color-mix(in srgb, var(--ok) 35%, transparent); }
|
|
||||||
.chip.notready { background: color-mix(in srgb, var(--err) 18%, transparent); color: var(--err); border-color: color-mix(in srgb, var(--err) 35%, transparent); }
|
|
||||||
.chip.warming { background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); border-color: color-mix(in srgb, var(--warn) 35%, transparent); }
|
|
||||||
|
|
||||||
/* ── Dashboard stat strip ────────────────────────────────────────── */
|
|
||||||
.statstrip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
|
|
||||||
@media (max-width: 1100px) { .statstrip { grid-template-columns: repeat(2, 1fr); } }
|
|
||||||
|
|
||||||
.kv { display: grid; grid-template-columns: 160px 1fr; gap: 6px 14px;
|
|
||||||
padding: 4px 0; font-size: 13px; }
|
|
||||||
.kv .k { color: var(--fg-dim); }
|
|
||||||
.kv .v { font-family: var(--mono); color: var(--fg); word-break: break-all; }
|
|
||||||
.kv .v.warn { color: var(--warn); }
|
|
||||||
.kv .v.err { color: var(--err); }
|
|
||||||
.kv .v.ok { color: var(--ok); }
|
|
||||||
|
|
||||||
/* ── Image rows: amber tint on un-bootable images ────────────────── */
|
|
||||||
tr.unbootable td { background: color-mix(in srgb, var(--warn) 7%, transparent) !important; }
|
|
||||||
tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
|
|
||||||
.row-warn { color: var(--warn); font-size: 11.5px; margin-top: 2px; }
|
|
||||||
|
|
||||||
/* ── Table source badge ──────────────────────────────────────────── */
|
|
||||||
.src-badge { font-family: var(--mono); font-size: 11px; padding: 1px 6px;
|
|
||||||
border-radius: 4px; background: var(--bg-elev); color: var(--fg-dim); }
|
|
||||||
.src-badge.nfs { background: color-mix(in srgb, #7cd3ff 18%, var(--bg-elev));
|
|
||||||
color: color-mix(in srgb, #7cd3ff 90%, var(--fg)); }
|
|
||||||
|
|
||||||
/* ── NFS rows ────────────────────────────────────────────────────── */
|
|
||||||
.nfs-row { display: grid; grid-template-columns: 32px 1fr auto auto auto; align-items: center;
|
|
||||||
gap: 14px; padding: 10px 14px; background: var(--bg-panel-2);
|
|
||||||
border-left: 3px solid var(--accent); border-radius: var(--radius); }
|
|
||||||
.nfs-row.down { border-left-color: var(--err); }
|
|
||||||
.nfs-row .id { font-family: var(--mono); font-size: 12.5px; color: var(--fg); }
|
|
||||||
.nfs-row .meta { color: var(--fg-dim); font-size: 12px; }
|
|
||||||
.nfs-row .err { color: var(--err); font-size: 11.5px; word-break: break-all; }
|
|
||||||
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
|
||||||
.dot.ok { background: var(--ok); }
|
|
||||||
.dot.err { background: var(--err); }
|
|
||||||
.dot.warn { background: var(--warn); }
|
|
||||||
|
|
||||||
/* Inline form rows. */
|
|
||||||
.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; }
|
|
||||||
@media (max-width: 900px) { .form-row { grid-template-columns: 1fr; } }
|
|
||||||
|
|
||||||
/* ── Gate queue "horse race" visual ──────────────────────────────── */
|
|
||||||
.gate-track {
|
.gate-track {
|
||||||
display: grid; gap: 6px;
|
display: grid; gap: 6px;
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
@@ -403,16 +266,59 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
|
|||||||
.msg.err { color: var(--err); }
|
.msg.err { color: var(--err); }
|
||||||
.msg.ok { color: var(--ok); }
|
.msg.ok { color: var(--ok); }
|
||||||
|
|
||||||
|
/* ── Top bar readiness chip ──────────────────────────────────────── */
|
||||||
|
.chip.ready { background: #103428; color: var(--ok); border-color: #1a4f3c; }
|
||||||
|
.chip.notready { background: #3a1515; color: var(--err); border-color: #5a1f1f; }
|
||||||
|
.chip.warming { background: #3a2a10; color: var(--warn); border-color: #4a3a18; }
|
||||||
|
|
||||||
|
/* ── Dashboard stat strip ────────────────────────────────────────── */
|
||||||
|
.statstrip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
|
||||||
|
@media (max-width: 1100px) { .statstrip { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
|
||||||
|
.kv { display: grid; grid-template-columns: 160px 1fr; gap: 6px 14px;
|
||||||
|
padding: 4px 0; font-size: 13px; }
|
||||||
|
.kv .k { color: var(--fg-dim); }
|
||||||
|
.kv .v { font-family: var(--mono); color: var(--fg); word-break: break-all; }
|
||||||
|
.kv .v.warn { color: var(--warn); }
|
||||||
|
.kv .v.err { color: var(--err); }
|
||||||
|
.kv .v.ok { color: var(--ok); }
|
||||||
|
|
||||||
|
/* ── Image rows: amber tint on un-bootable images (Bootimus pattern) ── */
|
||||||
|
tr.unbootable td { background: rgba(255, 179, 71, 0.07) !important; }
|
||||||
|
tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
|
||||||
|
.row-warn { color: var(--warn); font-size: 11.5px; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ── Table source badge ──────────────────────────────────────────── */
|
||||||
|
.src-badge { font-family: var(--mono); font-size: 11px; padding: 1px 6px;
|
||||||
|
border-radius: 4px; background: var(--bg-elev); color: var(--fg-dim); }
|
||||||
|
.src-badge.nfs { background: #122a3a; color: #7cd3ff; }
|
||||||
|
|
||||||
|
/* ── NFS modal-ish add form ──────────────────────────────────────── */
|
||||||
|
.nfs-row { display: grid; grid-template-columns: 32px 1fr auto auto auto; align-items: center;
|
||||||
|
gap: 14px; padding: 10px 14px; background: var(--bg-panel-2);
|
||||||
|
border-left: 3px solid var(--accent); border-radius: var(--radius); }
|
||||||
|
.nfs-row.down { border-left-color: var(--err); }
|
||||||
|
.nfs-row .id { font-family: var(--mono); font-size: 12.5px; color: var(--fg); }
|
||||||
|
.nfs-row .meta { color: var(--fg-dim); font-size: 12px; }
|
||||||
|
.nfs-row .err { color: var(--err); font-size: 11.5px; word-break: break-all; }
|
||||||
|
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||||
|
.dot.ok { background: var(--ok); }
|
||||||
|
.dot.err { background: var(--err); }
|
||||||
|
.dot.warn { background: var(--warn); }
|
||||||
|
|
||||||
|
/* ── Inline form rows (used by Network + NFS add) ───────────────── */
|
||||||
|
.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; }
|
||||||
|
@media (max-width: 900px) { .form-row { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
/* ── Terminal pane ──────────────────────────────────────────────── */
|
/* ── Terminal pane ──────────────────────────────────────────────── */
|
||||||
.terminal {
|
.terminal {
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background: var(--terminal-bg);
|
background: #06090e;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: calc(100vh - var(--topbar-h) - 90px);
|
height: calc(100vh - var(--topbar-h) - 90px);
|
||||||
min-height: 480px;
|
min-height: 480px;
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
}
|
}
|
||||||
.terminal .pane {
|
.terminal .pane {
|
||||||
flex: 1; overflow: auto;
|
flex: 1; overflow: auto;
|
||||||
@@ -424,37 +330,37 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
|
|||||||
.terminal .pane .lvl-error { color: var(--err); }
|
.terminal .pane .lvl-error { color: var(--err); }
|
||||||
.terminal .pane .lvl-warn { color: var(--warn); }
|
.terminal .pane .lvl-warn { color: var(--warn); }
|
||||||
.terminal .pane .lvl-info { color: #cfd6e2; }
|
.terminal .pane .lvl-info { color: #cfd6e2; }
|
||||||
.terminal .pane .lvl-debug { color: #8b94a8; }
|
.terminal .pane .lvl-debug { color: var(--fg-dim); }
|
||||||
.terminal .pane .lvl-trace { color: #5a6379; }
|
.terminal .pane .lvl-trace { color: var(--fg-dimmer); }
|
||||||
.terminal .pane .ts { color: #5a6379; }
|
.terminal .pane .ts { color: var(--fg-dimmer); }
|
||||||
.terminal .pane .tg { color: #7cd3ff; }
|
.terminal .pane .tg { color: #7cd3ff; }
|
||||||
.terminal .pane .echo { color: var(--accent); }
|
.terminal .pane .echo { color: var(--accent); }
|
||||||
.terminal .input-row {
|
.terminal .input-row {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
background: #0a0e15;
|
background: #0a0e15;
|
||||||
border-top: 1px solid #1d2330;
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.terminal .input-row .prompt { color: var(--accent); font-family: var(--mono); }
|
.terminal .input-row .prompt { color: var(--accent); font-family: var(--mono); }
|
||||||
.terminal .input-row input {
|
.terminal .input-row input {
|
||||||
flex: 1; background: transparent; border: 0; color: #e4e8ef;
|
flex: 1; background: transparent; border: 0; color: var(--fg);
|
||||||
font: inherit; font-family: var(--mono); font-size: 13px;
|
font: inherit; font-family: var(--mono); font-size: 13px;
|
||||||
outline: none; padding: 4px 0;
|
outline: none; padding: 4px 0;
|
||||||
}
|
}
|
||||||
.terminal .toolbar {
|
.terminal .toolbar {
|
||||||
display: flex; gap: 8px; align-items: center;
|
display: flex; gap: 8px; align-items: center;
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
background: #0a0e15;
|
background: var(--bg-panel-2);
|
||||||
border-bottom: 1px solid #1d2330;
|
border-bottom: 1px solid var(--border);
|
||||||
font-size: 12px; color: #8a94a7;
|
font-size: 12px; color: var(--fg-dim);
|
||||||
}
|
}
|
||||||
.terminal .toolbar .right { margin-left: auto; display: flex; gap: 6px; }
|
.terminal .toolbar .right { margin-left: auto; display: flex; gap: 6px; }
|
||||||
.terminal .toolbar button {
|
.terminal .toolbar button {
|
||||||
padding: 3px 9px; font-size: 11px;
|
padding: 3px 9px; font-size: 11px;
|
||||||
background: transparent; color: #8a94a7; border: 1px solid #1d2330;
|
background: transparent; color: var(--fg-dim); border: 1px solid var(--border);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.terminal .toolbar button:hover { color: #e4e8ef; background: #1d2330; }
|
.terminal .toolbar button:hover { color: var(--fg); background: var(--bg-elev); }
|
||||||
|
|
||||||
/* ── About card ─────────────────────────────────────────────────── */
|
/* ── About card ─────────────────────────────────────────────────── */
|
||||||
.about-hero { padding: 20px 24px; }
|
.about-hero { padding: 20px 24px; }
|
||||||
|
|||||||
+6
-160
@@ -75,26 +75,6 @@
|
|||||||
return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
|
return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Animated "anvil + progress bar" widget. Built once here and inlined
|
|
||||||
// wherever a card wants to convey "an image is being forged onto the
|
|
||||||
// bench right now." Used on the dashboard and on the Forge Gate.
|
|
||||||
function forgeProgressWidget(imaging, gateTotal) {
|
|
||||||
const total = Math.max(gateTotal, imaging, 1);
|
|
||||||
const pct = imaging > 0 ? Math.round((imaging / total) * 100) : 0;
|
|
||||||
const root = el('div', {class: 'forge-progress' + (imaging === 0 ? ' idle' : '')}, [
|
|
||||||
el('div', {class: 'anvil', 'aria-hidden': 'true'}),
|
|
||||||
el('div', {class: 'info'}, [
|
|
||||||
el('div', {class: 'label'},
|
|
||||||
imaging === 0
|
|
||||||
? 'No active imaging'
|
|
||||||
: (imaging + ' of ' + total + ' device' + (total === 1 ? '' : 's') + ' being forged')),
|
|
||||||
el('div', {class: 'bar-track'},
|
|
||||||
el('div', {class: 'bar-fill', style: 'width:' + pct + '%'})),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Categorize an ISO row's "bootable now" status — drives the amber
|
// Categorize an ISO row's "bootable now" status — drives the amber
|
||||||
// tint borrowed from Bootimus v0.1.62. Returns {ok, reason}.
|
// tint borrowed from Bootimus v0.1.62. Returns {ok, reason}.
|
||||||
function bootability(iso, settings) {
|
function bootability(iso, settings) {
|
||||||
@@ -132,14 +112,11 @@
|
|||||||
ipxeOk ? 'Bootloaders bundled, accepting clients'
|
ipxeOk ? 'Bootloaders bundled, accepting clients'
|
||||||
: 'No iPXE binaries bundled'),
|
: 'No iPXE binaries bundled'),
|
||||||
])),
|
])),
|
||||||
el('div', {class: 'card'}, [
|
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
||||||
el('div', {class: 'stat', style: 'padding-bottom:0'}, [
|
el('div', {class: 'label'}, 'Imaging now'),
|
||||||
el('div', {class: 'label'}, 'Imaging now'),
|
el('div', {class: 'value'}, String(status.imaging_count || 0)),
|
||||||
el('div', {class: 'value'}, String(status.imaging_count || 0)),
|
el('div', {class: 'trend'}, (status.waiting_count || 0) + ' waiting at gate'),
|
||||||
el('div', {class: 'trend'}, (status.waiting_count || 0) + ' waiting at gate'),
|
])),
|
||||||
]),
|
|
||||||
forgeProgressWidget(status.imaging_count || 0, status.gate_count || 0),
|
|
||||||
]),
|
|
||||||
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
||||||
el('div', {class: 'label'}, 'Images available'),
|
el('div', {class: 'label'}, 'Images available'),
|
||||||
el('div', {class: 'value'}, String(isos.length)),
|
el('div', {class: 'value'}, String(isos.length)),
|
||||||
@@ -291,13 +268,7 @@
|
|||||||
: el('div', {class:'empty'},
|
: el('div', {class:'empty'},
|
||||||
'No clients at the gate. Boot a client and choose "Gated Deployment" in the PXE menu.');
|
'No clients at the gate. Boot a client and choose "Gated Deployment" in the PXE menu.');
|
||||||
|
|
||||||
const imaging = gates.filter(g => g.assigned_target).length;
|
|
||||||
|
|
||||||
return el('div', {class:'grid'}, [
|
return el('div', {class:'grid'}, [
|
||||||
el('div', {class:'card'}, [
|
|
||||||
el('header', {}, el('h2', {}, 'Forge')),
|
|
||||||
forgeProgressWidget(imaging, gates.length),
|
|
||||||
]),
|
|
||||||
el('div', {class:'card'}, [
|
el('div', {class:'card'}, [
|
||||||
el('header', {}, el('h2', {}, 'Launch an image across the gate')),
|
el('header', {}, el('h2', {}, 'Launch an image across the gate')),
|
||||||
el('div', {class:'body'}, [
|
el('div', {class:'body'}, [
|
||||||
@@ -505,99 +476,6 @@
|
|||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
|
||||||
hosts: async () => {
|
|
||||||
const [{ hosts = [] }, isos] = await Promise.all([
|
|
||||||
getJSON('/api/hosts'), getJSON('/api/isos'),
|
|
||||||
]);
|
|
||||||
const targets = isos.flatMap(i => i.boot_entries.map(e => ({
|
|
||||||
id: e.id, title: e.title + ' — ' + familyLabel(i.introspection.family),
|
|
||||||
})));
|
|
||||||
|
|
||||||
// Reserved menu shortcuts that the operator might want to bind.
|
|
||||||
const reserved = [
|
|
||||||
{id: '_local', title: '↳ Boot from Local HDD (built-in)'},
|
|
||||||
{id: '_gate', title: '↳ Gated Deployment (built-in)'},
|
|
||||||
{id: '_tools_menu', title: '↳ Tools menu (built-in)'},
|
|
||||||
];
|
|
||||||
|
|
||||||
const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff', spellcheck:'false'});
|
|
||||||
const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'});
|
|
||||||
const targetSel = el('select', {},
|
|
||||||
[el('option', {value:''}, '— choose a target —')]
|
|
||||||
.concat(reserved.map(t => el('option', {value: t.id}, t.title)))
|
|
||||||
.concat(targets.map(t => el('option', {value: t.id}, t.title))));
|
|
||||||
const msg = el('div', {class:'msg'});
|
|
||||||
|
|
||||||
const upsertBtn = el('button', {onclick: async () => {
|
|
||||||
if (!macInput.value || !targetSel.value) {
|
|
||||||
msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
|
|
||||||
}
|
|
||||||
const r = await postJSON('/api/hosts', {
|
|
||||||
mac: macInput.value, target: targetSel.value, label: labelInput.value,
|
|
||||||
});
|
|
||||||
if (r.ok) {
|
|
||||||
msg.textContent = 'Saved.'; msg.className = 'msg ok';
|
|
||||||
render('hosts');
|
|
||||||
} else {
|
|
||||||
const t = await r.text();
|
|
||||||
msg.textContent = 'Save failed: ' + t; msg.className = 'msg err';
|
|
||||||
}
|
|
||||||
}}, 'Bind MAC to target');
|
|
||||||
|
|
||||||
const rows = hosts.map(h => el('tr', {}, [
|
|
||||||
el('td', {class:'mono'}, h.mac),
|
|
||||||
el('td', {}, h.label || el('span', {class:'tag'}, '(unlabeled)')),
|
|
||||||
el('td', {class:'mono'}, h.target),
|
|
||||||
el('td', {}, fmtAgo(h.updated_at)),
|
|
||||||
el('td', {style:'text-align:right'},
|
|
||||||
el('button', {class:'danger', onclick: async () => {
|
|
||||||
if (!confirm('Remove binding for ' + h.mac + '?')) return;
|
|
||||||
await fetch('/api/hosts/' + encodeURIComponent(h.mac), {method:'DELETE'});
|
|
||||||
render('hosts');
|
|
||||||
}}, 'Remove')),
|
|
||||||
]));
|
|
||||||
|
|
||||||
const table = hosts.length
|
|
||||||
? el('table', {}, [
|
|
||||||
el('thead', {}, el('tr', {}, [
|
|
||||||
el('th',{},'MAC'), el('th',{},'Label'),
|
|
||||||
el('th',{},'Target'), el('th',{},'Updated'), el('th',{},''),
|
|
||||||
])),
|
|
||||||
el('tbody', {}, rows),
|
|
||||||
])
|
|
||||||
: el('div', {class:'empty'}, 'No host bindings yet. Pin a MAC to a boot target to skip the menu for that machine.');
|
|
||||||
|
|
||||||
return el('div', {class:'grid'}, [
|
|
||||||
el('div', {class:'card'}, [
|
|
||||||
el('header', {}, el('h2', {}, 'Pin MAC to boot target')),
|
|
||||||
el('div', {class:'body'}, [
|
|
||||||
el('div', {class:'form-row'}, [
|
|
||||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'MAC address'), macInput]),
|
|
||||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]),
|
|
||||||
el('label', {class:'field', style:'grid-column:1 / -1'}, [
|
|
||||||
el('span', {class:'name'}, 'Target'),
|
|
||||||
targetSel,
|
|
||||||
el('span', {class:'hint'},
|
|
||||||
'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'),
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
upsertBtn, msg,
|
|
||||||
el('p', {class:'msg', style:'margin-top:14px'},
|
|
||||||
'When a client with a bound MAC requests boot.ipxe, PXEForge ' +
|
|
||||||
'short-circuits past the interactive menu and chains directly. ' +
|
|
||||||
'Inspired by Tinkerbell smee\'s MAC-prepended URL pattern.'),
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
el('div', {class:'card'}, [
|
|
||||||
el('header', {}, [
|
|
||||||
el('h2', {}, 'Bound hosts'),
|
|
||||||
el('span', {class:'sub'}, hosts.length + ' binding' + (hosts.length === 1 ? '' : 's')),
|
|
||||||
]),
|
|
||||||
table,
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
|
|
||||||
terminal: async () => {
|
terminal: async () => {
|
||||||
// Two-pane layout: live log on top (auto-scrolling), command line
|
// Two-pane layout: live log on top (auto-scrolling), command line
|
||||||
// on bottom. Mirrors the Minecraft-server console feel from the
|
// on bottom. Mirrors the Minecraft-server console feel from the
|
||||||
@@ -760,35 +638,10 @@
|
|||||||
network: 'Network',
|
network: 'Network',
|
||||||
gate: 'Forge Gate',
|
gate: 'Forge Gate',
|
||||||
storage: 'Storage',
|
storage: 'Storage',
|
||||||
hosts: 'Hosts',
|
|
||||||
terminal: 'Terminal',
|
terminal: 'Terminal',
|
||||||
about: 'About',
|
about: 'About',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Theme toggle. The data-attribute is set on <html> by the inline
|
|
||||||
// script in index.html before paint; we just flip it here and persist.
|
|
||||||
function applyTheme(theme) {
|
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
|
||||||
try { localStorage.setItem('pxeforge-theme', theme); } catch {}
|
|
||||||
}
|
|
||||||
function currentTheme() {
|
|
||||||
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
|
|
||||||
}
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const btn = $('#theme-toggle');
|
|
||||||
if (btn) {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
applyTheme(currentTheme() === 'light' ? 'dark' : 'light');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Keyboard shortcut: T toggles theme (skip when typing in an input).
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key !== 't' && e.key !== 'T') return;
|
|
||||||
if (/^(INPUT|TEXTAREA|SELECT)$/.test((e.target && e.target.tagName) || '')) return;
|
|
||||||
applyTheme(currentTheme() === 'light' ? 'dark' : 'light');
|
|
||||||
});
|
|
||||||
|
|
||||||
let currentBody = null;
|
let currentBody = null;
|
||||||
|
|
||||||
async function render(view) {
|
async function render(view) {
|
||||||
@@ -801,13 +654,7 @@
|
|||||||
try { currentBody._cleanup(); } catch (e) { /* ignore */ }
|
try { currentBody._cleanup(); } catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
// Animated anvil loader. Replaces the old text "Loading…" so the
|
root.appendChild(el('div', {class:'msg'}, 'Loading…'));
|
||||||
// user gets a sense of "the forge is heating up" instead of a flat
|
|
||||||
// spinner. The SVG itself drives all animation via SMIL, no JS.
|
|
||||||
root.appendChild(el('div', {class:'loader'}, [
|
|
||||||
el('div', {class:'anvil'}),
|
|
||||||
el('div', {}, 'Heating the forge…'),
|
|
||||||
]));
|
|
||||||
try {
|
try {
|
||||||
const body = await views[view]();
|
const body = await views[view]();
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
@@ -827,7 +674,6 @@
|
|||||||
$$('[data-bind=iso_count],[data-bind=iso_count2]').forEach(n => n.textContent = String(s.iso_count));
|
$$('[data-bind=iso_count],[data-bind=iso_count2]').forEach(n => n.textContent = String(s.iso_count));
|
||||||
$$('[data-bind=client_count],[data-bind=client_count2]').forEach(n => n.textContent = String(s.client_count));
|
$$('[data-bind=client_count],[data-bind=client_count2]').forEach(n => n.textContent = String(s.client_count));
|
||||||
$$('[data-bind=gate_count],[data-bind=gate_count2]').forEach(n => n.textContent = String(s.gate_count));
|
$$('[data-bind=gate_count],[data-bind=gate_count2]').forEach(n => n.textContent = String(s.gate_count));
|
||||||
$$('[data-bind=host_count]').forEach(n => n.textContent = String(s.host_bindings || 0));
|
|
||||||
const chip = $('[data-bind=ready_chip]');
|
const chip = $('[data-bind=ready_chip]');
|
||||||
if (chip) {
|
if (chip) {
|
||||||
if (r.ok) { chip.textContent = '● ready'; chip.className = 'chip ready'; }
|
if (r.ok) { chip.textContent = '● ready'; chip.className = 'chip ready'; }
|
||||||
|
|||||||
@@ -3,24 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="color-scheme" content="dark light" />
|
|
||||||
<title>PXEForge</title>
|
<title>PXEForge</title>
|
||||||
<link rel="stylesheet" href="/assets/app.css" />
|
<link rel="stylesheet" href="/assets/app.css" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg" />
|
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg" />
|
||||||
<!-- Theme is read from localStorage *before* paint to avoid the
|
|
||||||
dark→light flash on every navigation. Falls back to the OS
|
|
||||||
preference and finally to dark. -->
|
|
||||||
<script>
|
|
||||||
(function() {
|
|
||||||
try {
|
|
||||||
var stored = localStorage.getItem('pxeforge-theme');
|
|
||||||
var theme = stored || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
|
||||||
} catch (e) {
|
|
||||||
document.documentElement.setAttribute('data-theme', 'dark');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
@@ -29,7 +14,7 @@
|
|||||||
<img src="/assets/logo.svg" alt="" />
|
<img src="/assets/logo.svg" alt="" />
|
||||||
<div>
|
<div>
|
||||||
<strong>PXEForge</strong>
|
<strong>PXEForge</strong>
|
||||||
<div class="sub">v<span data-bind="version">0.2.0</span></div>
|
<div class="sub">v<span data-bind="version">0.1.0</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav>
|
<nav>
|
||||||
@@ -43,10 +28,6 @@
|
|||||||
Storage
|
Storage
|
||||||
<span class="count" data-bind="iso_count">0</span>
|
<span class="count" data-bind="iso_count">0</span>
|
||||||
</a>
|
</a>
|
||||||
<a data-view="hosts">
|
|
||||||
Hosts
|
|
||||||
<span class="count" data-bind="host_count">0</span>
|
|
||||||
</a>
|
|
||||||
<a data-view="terminal">Terminal</a>
|
<a data-view="terminal">Terminal</a>
|
||||||
<a data-view="about">About</a>
|
<a data-view="about">About</a>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -63,26 +44,6 @@
|
|||||||
<span class="chip"><strong data-bind="iso_count2">0</strong> images</span>
|
<span class="chip"><strong data-bind="iso_count2">0</strong> images</span>
|
||||||
<span class="chip"><strong data-bind="client_count2">0</strong> clients</span>
|
<span class="chip"><strong data-bind="client_count2">0</strong> clients</span>
|
||||||
<span class="chip"><strong data-bind="gate_count2">0</strong> at gate</span>
|
<span class="chip"><strong data-bind="gate_count2">0</strong> at gate</span>
|
||||||
<button id="theme-toggle" class="theme-toggle" type="button"
|
|
||||||
aria-label="Toggle light/dark theme" title="Toggle theme (T)">
|
|
||||||
<!-- Two glyphs; CSS shows whichever matches the active theme. -->
|
|
||||||
<svg class="t-sun" viewBox="0 0 24 24" width="18" height="18" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
||||||
<circle cx="12" cy="12" r="4.2"/>
|
|
||||||
<line x1="12" y1="2.5" x2="12" y2="5.5"/>
|
|
||||||
<line x1="12" y1="18.5" x2="12" y2="21.5"/>
|
|
||||||
<line x1="2.5" y1="12" x2="5.5" y2="12"/>
|
|
||||||
<line x1="18.5" y1="12" x2="21.5" y2="12"/>
|
|
||||||
<line x1="5.2" y1="5.2" x2="7.3" y2="7.3"/>
|
|
||||||
<line x1="16.7" y1="16.7" x2="18.8" y2="18.8"/>
|
|
||||||
<line x1="5.2" y1="18.8" x2="7.3" y2="16.7"/>
|
|
||||||
<line x1="16.7" y1="7.3" x2="18.8" y2="5.2"/>
|
|
||||||
</svg>
|
|
||||||
<svg class="t-moon" viewBox="0 0 24 24" width="18" height="18" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M20.5 14A8 8 0 0 1 10 3.5 a8 8 0 1 0 10.5 10.5z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="main" id="view-root"></main>
|
<main class="main" id="view-root"></main>
|
||||||
|
|||||||
+4
-11
@@ -23,14 +23,7 @@ pub fn app_css() -> &'static str { APP_CSS }
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn logo_svg() -> &'static str { LOGO_SVG }
|
pub fn logo_svg() -> &'static str { LOGO_SVG }
|
||||||
|
|
||||||
/// Animated forging anvil — sparks rise + glow pulse. Used for the
|
const INDEX_HTML: &str = include_str!("index.html");
|
||||||
/// imaging-progress widget and any "I'm working" loading state. Pure
|
const APP_CSS: &str = include_str!("app.css");
|
||||||
/// SVG + SMIL, no JS, no GIF.
|
const APP_JS: &str = include_str!("app.js");
|
||||||
#[must_use]
|
const LOGO_SVG: &str = include_str!("logo.svg");
|
||||||
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");
|
|
||||||
|
|||||||
+12
-30
@@ -1,32 +1,14 @@
|
|||||||
<svg viewBox="0 0 200 130" xmlns="http://www.w3.org/2000/svg" fill="none">
|
<svg viewBox="0 0 96 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<title>PXEForge</title>
|
<title>PXEForge</title>
|
||||||
<defs>
|
<!-- Anvil body -->
|
||||||
<linearGradient id="anvilBody" x1="0" y1="0" x2="0" y2="1">
|
<path d="M6 22 H82 L70 36 H46 V44 H58 V50 H30 V44 H42 V36 H22 Z" fill="#f0823a" stroke="#3a1f08" stroke-width="1.2"/>
|
||||||
<stop offset="0%" stop-color="#aab3c2"/>
|
<!-- Horn highlight -->
|
||||||
<stop offset="55%" stop-color="#7d8696"/>
|
<path d="M6 22 L20 22 L14 28 L6 28 Z" fill="#ffb066"/>
|
||||||
<stop offset="100%" stop-color="#525a6b"/>
|
<!-- Stand + base -->
|
||||||
</linearGradient>
|
<rect x="34" y="50" width="20" height="4" fill="#3a1f08"/>
|
||||||
<linearGradient id="anvilFace" x1="0" y1="0" x2="0" y2="1">
|
<rect x="22" y="54" width="44" height="6" fill="#1c1107"/>
|
||||||
<stop offset="0%" stop-color="#cdd5e1"/>
|
<!-- Subtle spark -->
|
||||||
<stop offset="100%" stop-color="#9aa3b3"/>
|
<circle cx="86" cy="16" r="1.5" fill="#ffd79a"/>
|
||||||
</linearGradient>
|
<circle cx="90" cy="22" r="1" fill="#ffd79a"/>
|
||||||
<linearGradient id="anvilBase" x1="0" y1="0" x2="0" y2="1">
|
<circle cx="82" cy="12" r="1" fill="#ffd79a"/>
|
||||||
<stop offset="0%" stop-color="#3b4252"/>
|
|
||||||
<stop offset="100%" stop-color="#252a36"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<!-- Horn (left point) + face (top) -->
|
|
||||||
<path d="M14 36 L72 30 L162 30 L162 46 L72 46 Z"
|
|
||||||
fill="url(#anvilFace)" stroke="#1d2330" stroke-width="2.4" stroke-linejoin="round"/>
|
|
||||||
<!-- Body / waist -->
|
|
||||||
<path d="M70 46 L160 46 L142 70 L88 70 Z"
|
|
||||||
fill="url(#anvilBody)" stroke="#1d2330" stroke-width="2.4" stroke-linejoin="round"/>
|
|
||||||
<!-- Base plinth -->
|
|
||||||
<path d="M62 96 L168 96 L160 110 L70 110 Z"
|
|
||||||
fill="url(#anvilBase)" stroke="#0d1018" stroke-width="2.4" stroke-linejoin="round"/>
|
|
||||||
<!-- Pillar between body and base -->
|
|
||||||
<rect x="92" y="70" width="46" height="26" fill="url(#anvilBody)"
|
|
||||||
stroke="#1d2330" stroke-width="2.4"/>
|
|
||||||
<!-- Highlight along top face -->
|
|
||||||
<line x1="74" y1="34" x2="158" y2="34" stroke="#e6ecf5" stroke-width="1.2" opacity="0.7"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 650 B |
@@ -1,156 +0,0 @@
|
|||||||
# Phase 6 — recommendations
|
|
||||||
|
|
||||||
The v0.2.0 cut leaves PXEForge in a state where the entire protocol stack
|
|
||||||
and operator UI are exercised by 66 automated tests, the container is
|
|
||||||
multi-arch buildable, and the image ships at ~97 MB. What's left before
|
|
||||||
this looks and feels like a 1.0 product is mostly **real-hardware
|
|
||||||
validation** plus a small batch of features that can only sensibly be
|
|
||||||
designed once we've watched real machines image.
|
|
||||||
|
|
||||||
This doc is a punch list, ordered by what I'd do first if I had a week.
|
|
||||||
|
|
||||||
## Tier 1 — must-do before we call anything "stable"
|
|
||||||
|
|
||||||
### 1. Real-hardware validation matrix
|
|
||||||
|
|
||||||
We have CI tests for every protocol leg, but no end-to-end PXE on real
|
|
||||||
firmware. Build a small matrix:
|
|
||||||
|
|
||||||
| client | firmware | OS family | pass criteria |
|
|
||||||
|-------------------------------------|-----------|------------|---------------------------|
|
|
||||||
| any 10-y-old mini-PC | Legacy BIOS | Ubuntu Server 24.04 | gets to GRUB / installer |
|
|
||||||
| Intel NUC / similar | UEFI x64 | Windows 11 | reaches "where do you want to install" |
|
|
||||||
| Raspberry Pi 4 | UEFI ARM64 | Raspberry Pi OS | gets to login prompt |
|
|
||||||
| Dell / HP business laptop | UEFI x64 | Fedora | one of: kernel boot or wimboot |
|
|
||||||
|
|
||||||
Add a `docs/HARDWARE_VALIDATION.md` checklist that records what worked,
|
|
||||||
firmware versions, and any quirks. Anything weird gets a regression
|
|
||||||
test in the relevant crate.
|
|
||||||
|
|
||||||
### 2. Boot menu hotkey + UI accessibility audit
|
|
||||||
|
|
||||||
The iPXE menu has number-key + letter hotkeys but no documentation on
|
|
||||||
what they map to. Generate a printable cheat-sheet from
|
|
||||||
`crates/http-api/src/ipxe_script.rs` so operators don't have to read
|
|
||||||
the source. Run a screen-reader pass over the web UI — most of it
|
|
||||||
should be fine since we're mostly tables + form labels, but the
|
|
||||||
Terminal pane and the SSE log output need explicit `aria-live`
|
|
||||||
regions.
|
|
||||||
|
|
||||||
### 3. Boot.wim re-patch detection
|
|
||||||
|
|
||||||
Bootimus v0.1.62's "fingerprint of patched inputs + Save & Re-patch"
|
|
||||||
pattern is a small but high-value feature: when an operator changes
|
|
||||||
the SMB host override or upgrades wimboot, the existing patched
|
|
||||||
boot.wim is silently stale. We should:
|
|
||||||
|
|
||||||
- Hash the inputs (smb_host, smb_share, startnet.cmd content,
|
|
||||||
wimboot binary digest) into the IsoMeta;
|
|
||||||
- Surface a "needs re-patch" warning on the Storage tab when the
|
|
||||||
hash drifts;
|
|
||||||
- Add a "Re-patch SMB" button that re-runs the WimPatcher.
|
|
||||||
|
|
||||||
## Tier 2 — features that round out pre-beta
|
|
||||||
|
|
||||||
### 4. Auto-install file library
|
|
||||||
|
|
||||||
iVentoy and Bootimus both support attaching `autounattend.xml` /
|
|
||||||
`preseed.cfg` / `kickstart.cfg` to an image. The mechanics are
|
|
||||||
straightforward: store files under `<work_dir>/autoinstall/<distro>/`,
|
|
||||||
expose CRUD via `/api/autoinstall-files`, and modify the WimPatcher
|
|
||||||
+ Linux kernel cmdline to fetch + apply the right file. Placeholders
|
|
||||||
worth supporting (Bootimus pattern): `{{MAC}}`, `{{HOSTNAME}}`,
|
|
||||||
`{{IP}}`, `{{SERVER_ADDR}}`, `{{IMAGE_FILENAME}}`, substituted
|
|
||||||
serve-side per request.
|
|
||||||
|
|
||||||
### 5. Wake-on-LAN trigger
|
|
||||||
|
|
||||||
A natural pair with per-MAC host bindings: bind a MAC to an image,
|
|
||||||
then click "Wake & Image" to send the magic packet and let PXEForge
|
|
||||||
do the rest. Implementation is small (`udp/9` broadcast, magic packet
|
|
||||||
construction) but it makes the bound-host workflow feel instant.
|
|
||||||
|
|
||||||
### 6. Distro profile manifest
|
|
||||||
|
|
||||||
Today, distro detection lives as Rust match arms in `introspect.rs`
|
|
||||||
and the kernel cmdline templates live in `store.rs`. Bootimus extracts
|
|
||||||
this into a JSON manifest that ships embedded in the binary AND is
|
|
||||||
overridable by the operator at runtime — so a new distro can be added
|
|
||||||
without rebuilding the container. Worth porting; it'd let community
|
|
||||||
contributions land as PRs to a single JSON file.
|
|
||||||
|
|
||||||
### 7. Syslog receiver
|
|
||||||
|
|
||||||
`smee` ships one. The use case: WinPE / Linux installers can be
|
|
||||||
configured to syslog over the network to the PXE server; if we have
|
|
||||||
an endpoint and a place in the UI to view per-client diagnostics,
|
|
||||||
post-mortem on a failed install gets dramatically easier.
|
|
||||||
|
|
||||||
### 8. UEFI HTTP Boot validation
|
|
||||||
|
|
||||||
Option 60 = `HTTPClient` is wired up in `decide()` already, but
|
|
||||||
we've never tested it on real firmware. Some Dell + Lenovo UEFIs
|
|
||||||
prefer it over PXE-via-TFTP. A quick check on a real machine
|
|
||||||
(disable TFTP boot in firmware, force HTTP boot) and a regression
|
|
||||||
test would be nice.
|
|
||||||
|
|
||||||
## Tier 3 — bigger lifts, only if there's demand
|
|
||||||
|
|
||||||
### 9. Pure-Rust SMB server
|
|
||||||
|
|
||||||
`smbd` from Samba is ~80 MB of the runtime image. There are pure-Rust
|
|
||||||
SMB2 server crates (`smbd-server`, `smb-rs`) of varying maturity.
|
|
||||||
Replacing the dep would slim the image by ~40% and remove the
|
|
||||||
`CAP_SYS_ADMIN` requirement for SMB. Worth a spike, not necessarily
|
|
||||||
landable in Phase 6.
|
|
||||||
|
|
||||||
### 10. IPv6 / DHCPv6
|
|
||||||
|
|
||||||
PXE-over-IPv6 is real (RFC 5970). Some sites are v6-only. Worth
|
|
||||||
implementing once we know we have one. Until then, IPv4-only is the
|
|
||||||
right default — flipping the bit on v6 without v6 testing is asking
|
|
||||||
for silent breakage.
|
|
||||||
|
|
||||||
### 11. Multi-replica deployment
|
|
||||||
|
|
||||||
The current design assumes one PXEForge per broadcast domain. Two
|
|
||||||
proxies on the same L2 will race; the gate queue is in-memory, etc.
|
|
||||||
For HA we'd need to:
|
|
||||||
- Externalize the gate queue (Redis, etcd) or lean into "the menu is
|
|
||||||
cheap to refetch if a replica dies";
|
|
||||||
- Ensure DHCP proxy replies are deterministic so a client always
|
|
||||||
gets the same answer regardless of which replica replied;
|
|
||||||
- Document the L2 collision domain story.
|
|
||||||
|
|
||||||
This is a large lift and should only happen if someone's actually
|
|
||||||
asking for it.
|
|
||||||
|
|
||||||
### 12. Pi 4 / SBC quirks
|
|
||||||
|
|
||||||
Raspberry Pi netboot uses a specific DHCP option-43 vendor field +
|
|
||||||
TFTP path layout that PXEForge doesn't currently special-case. There's
|
|
||||||
a spec; the work is small once we have a Pi to test on.
|
|
||||||
|
|
||||||
## What I'd skip
|
|
||||||
|
|
||||||
- **A custom DHCP server (not proxy).** The proxy mode is the right
|
|
||||||
abstraction; full DHCP would need raw sockets + a lot of corner-case
|
|
||||||
handling for problems no operator wants us to solve.
|
|
||||||
- **A pluggable backend abstraction à la Tinkerbell.** Tinkerbell does
|
|
||||||
it because they integrate with k8s CRDs. PXEForge's "the file system
|
|
||||||
IS the database" model is simpler and good enough for the target
|
|
||||||
audience. Don't add a Backend trait until something asks for it.
|
|
||||||
- **Multiple language UIs.** Bootimus added these in v0.1.62 and the
|
|
||||||
translations are LLM-generated. Skip until we have real users
|
|
||||||
asking for non-English.
|
|
||||||
|
|
||||||
## Quick wins (could land in a single afternoon)
|
|
||||||
|
|
||||||
- Add a Grafana dashboard JSON to `deploy/grafana/` driven off the
|
|
||||||
new `/metrics` endpoint.
|
|
||||||
- A `pxeforge bench` subcommand that runs a 10-second internal load
|
|
||||||
test (synthetic gate joins) so an operator can sanity-check tuning.
|
|
||||||
- Ship a basic `docker-compose.yml` for the Unraid path that demos
|
|
||||||
the new themes / progress widget.
|
|
||||||
- Generate a printable single-page operator runbook from the README
|
|
||||||
+ architecture.md (e.g. `cargo xtask runbook`).
|
|
||||||
+1
-75
@@ -256,70 +256,7 @@ tab is one click from the brand bar.
|
|||||||
- Streaming uploads are already in place via axum multipart; the v0.1.62
|
- Streaming uploads are already in place via axum multipart; the v0.1.62
|
||||||
fix to "502 on big upload" doesn't apply.
|
fix to "502 on big upload" doesn't apply.
|
||||||
|
|
||||||
## Phase 5 — pre-beta hardening
|
## What's deferred to Phase 5
|
||||||
|
|
||||||
**Per-MAC host bindings** (`crates/core/src/host_bindings.rs`):
|
|
||||||
- New `HostBindings` registry maps a MAC → preferred `BootEntry::id`
|
|
||||||
(or one of the reserved menu shortcuts `_local`, `_gate`,
|
|
||||||
`_tools_menu`).
|
|
||||||
- Persisted to `<work_dir>/hosts.json`. Like `SettingsStore`, in-memory
|
|
||||||
is authoritative — disk corruption falls back to empty rather than
|
|
||||||
failing startup.
|
|
||||||
- Inspired by Tinkerbell `smee`'s MAC-prepended URL pattern. The DHCP
|
|
||||||
reply now embeds `?mac=${mac}` in the boot.ipxe URL; iPXE substitutes
|
|
||||||
the literal MAC client-side, so the HTTP layer can short-circuit
|
|
||||||
past the menu when a binding exists.
|
|
||||||
- `/api/hosts` GET / POST / DELETE drives the **Hosts** tab.
|
|
||||||
|
|
||||||
**Prometheus metrics** (`crates/core/src/metrics.rs`):
|
|
||||||
- Lock-free `AtomicU64`-backed counters + gauges. No `prometheus` /
|
|
||||||
`metrics-rs` dep — they bring a registry, runtime, and complexity
|
|
||||||
we don't need for a fixed set of metric families.
|
|
||||||
- Counters: DHCP replies (per arch label), DHCP declined, TFTP
|
|
||||||
transfers (per status label), TFTP bytes, HTTP requests (per route
|
|
||||||
label).
|
|
||||||
- Gauges: ISO count, client count, gate count, gate-imaging count,
|
|
||||||
NFS active mounts, uptime, build info.
|
|
||||||
- Exposed as plain Prometheus text at `/metrics`.
|
|
||||||
|
|
||||||
**Code cleanup pass**: clippy `--workspace --all-targets` is now
|
|
||||||
warning-free. Replaced `format!()`-into-`String` with
|
|
||||||
`std::fmt::Write::write!`, switched manual reverse comparators to
|
|
||||||
`Reverse`, fixed `map_or(false, …)` → `is_some_and`, and a handful of
|
|
||||||
other idiom fixes.
|
|
||||||
|
|
||||||
**UI overhaul** for the v0.2.0 pre-beta milestone:
|
|
||||||
- Light + dark themes via `:root[data-theme=light]` token swap.
|
|
||||||
Toggled by a top-right button or the `T` key. Persisted in
|
|
||||||
localStorage; pre-paint inline script avoids dark→light flash.
|
|
||||||
- New SVG logos: a refined anvil (`logo.svg`) and a SMIL-animated
|
|
||||||
`anvil-forge.svg` (rising sparks + pulsing underglow). Pure SVG —
|
|
||||||
no GIFs, no CSS keyframes for the sparks.
|
|
||||||
- "Forge progress" widget on the Dashboard and Forge Gate: animated
|
|
||||||
anvil paired with a `linear-gradient(warn → accent)` progress bar
|
|
||||||
with a moving sheen. Goes idle (greyscale, no sheen) at zero
|
|
||||||
imaging load.
|
|
||||||
- Loader replaced "Loading…" text with the same anvil.
|
|
||||||
- Sidebar gains a **Hosts** tab.
|
|
||||||
|
|
||||||
**Windows boot validation**:
|
|
||||||
- New integration test synthesizes an ISO9660 with the `SOURCES\BOOT.WIM`
|
|
||||||
sentinel, uploads it, and asserts:
|
|
||||||
1. introspection labels it `windows_pe` with `has_boot_wim=true`,
|
|
||||||
2. the boot entry is `BootKind::Wimboot` with all five canonical
|
|
||||||
files (`bootmgr`, `bootmgr.efi`, `bcd`, `boot.sdi`, `boot.wim`),
|
|
||||||
3. the rendered iPXE script chains wimboot with `initrd --name`
|
|
||||||
entries for each, and
|
|
||||||
4. **no** trust-store strings appear: `bcdedit`, `testsigning`,
|
|
||||||
`certutil`, `httpdisk`, `test-signed` are all explicitly
|
|
||||||
forbidden in the rendered output.
|
|
||||||
- WinPE bootstrap (`startnet.cmd`) now picks up Bootimus v0.1.58
|
|
||||||
fixes: explicit `net start Workstation` before `net use`, surfaces
|
|
||||||
errors instead of blind retries.
|
|
||||||
|
|
||||||
**Test count**: 66 → up from 56 in v0.1.0.
|
|
||||||
|
|
||||||
## What's deferred to Phase 6
|
|
||||||
|
|
||||||
- Full ISO9660 + Joliet + Rock Ridge parser (current lookup is plain ISO9660 — Debian ISOs with Rock Ridge extensions may miss some paths).
|
- Full ISO9660 + Joliet + Rock Ridge parser (current lookup is plain ISO9660 — Debian ISOs with Rock Ridge extensions may miss some paths).
|
||||||
- Real-hardware Windows boot validation (plumbing tested; no MS ISO pushed through the full pipeline yet).
|
- Real-hardware Windows boot validation (plumbing tested; no MS ISO pushed through the full pipeline yet).
|
||||||
@@ -331,14 +268,3 @@ other idiom fixes.
|
|||||||
- Pure-Rust SMB server (replace smbd) — slim image, no Samba.
|
- Pure-Rust SMB server (replace smbd) — slim image, no Samba.
|
||||||
- Auto-install / autounattend file library (Bootimus v0.1.58 pattern).
|
- Auto-install / autounattend file library (Bootimus v0.1.58 pattern).
|
||||||
- Per-client / per-group menus (Bootimus v0.1.16 pattern).
|
- Per-client / per-group menus (Bootimus v0.1.16 pattern).
|
||||||
- Real-hardware integration: at minimum a Linux ISO booted on a real
|
|
||||||
BIOS box, a Windows ISO booted via wimboot on a real UEFI box, and a
|
|
||||||
Pi 4 booting from an NFS-mounted Raspberry Pi OS ISO.
|
|
||||||
- Distro profile manifest (Bootimus v0.1.27 pattern) — currently
|
|
||||||
introspection logic is hard-coded; could become data-driven so an
|
|
||||||
operator can add a new distro profile from the UI without rebuilding.
|
|
||||||
- Wake-on-LAN trigger (Bootimus v0.1.16 pattern) — power-on a host then
|
|
||||||
imaging starts unattended via a per-MAC binding.
|
|
||||||
- Syslog receiver (smee feature) — capture client-side install syslog
|
|
||||||
for diagnostic visibility.
|
|
||||||
- IPv6 PXE / DHCPv6 — currently IPv4 only.
|
|
||||||
|
|||||||
@@ -1,403 +0,0 @@
|
|||||||
# Runbook: Boot a Linux machine from an ISO over the network
|
|
||||||
|
|
||||||
End-to-end walkthrough: spin up PXEForge, load an Ubuntu (or any
|
|
||||||
Linux) ISO into it, target a specific bare-metal or VM client by its
|
|
||||||
MAC address, and have that machine PXE-boot the installer over the
|
|
||||||
LAN — no USB stick, no console babysitting.
|
|
||||||
|
|
||||||
This runbook assumes:
|
|
||||||
|
|
||||||
- You have **one Linux host** to run the PXEForge container (any
|
|
||||||
distro with Docker / Podman; 2 GB RAM, ~50 GB disk for the ISO
|
|
||||||
library).
|
|
||||||
- That host sits on the **same broadcast domain / VLAN** as the
|
|
||||||
client you want to boot. PXE is L2-broadcast — routed/VLAN’d
|
|
||||||
networks need a DHCP relay and are out of scope here.
|
|
||||||
- An **existing DHCP server** is already handing out IP leases on
|
|
||||||
that VLAN (your home router, OPNsense, Windows Server, etc.).
|
|
||||||
PXEForge runs as a *DHCP proxy* — it never leases IPs, it only
|
|
||||||
layers the boot information on top of the existing DHCP exchange.
|
|
||||||
- The target client is configured to **PXE-boot** in BIOS/UEFI
|
|
||||||
firmware (usually `F12` boot menu → Network, or set as first boot
|
|
||||||
device).
|
|
||||||
|
|
||||||
If those don’t hold, stop and read [troubleshooting.md](troubleshooting.md)
|
|
||||||
or [docs/architecture.md](../docs/architecture.md) first.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. Pick your host’s LAN IP
|
|
||||||
|
|
||||||
You need the IPv4 address PXEForge will advertise to clients. From
|
|
||||||
the host:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ip -4 -o addr show | awk '{print $2, $4}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Pick the address on the interface that faces the PXE VLAN — for
|
|
||||||
example `10.0.0.5/24` on `eno1`. From here on we call it
|
|
||||||
`PXE_HOST_IP`.
|
|
||||||
|
|
||||||
> **Why this matters.** Every URL handed to clients (TFTP server,
|
|
||||||
> iPXE chain URL, ISO URL) is built from this IP. If PXEForge
|
|
||||||
> auto-detects the wrong interface or loopback, clients will fetch
|
|
||||||
> from an unreachable address and silently fail. The startup will
|
|
||||||
> *fail loudly* if it can only auto-detect a loopback address.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Run PXEForge
|
|
||||||
|
|
||||||
The MVP path is a single `docker run` against the published image,
|
|
||||||
with `--network host` so the container can see DHCP broadcasts on
|
|
||||||
the LAN.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p ~/pxeforge/isos ~/pxeforge/work
|
|
||||||
|
|
||||||
docker run -d --name pxeforge \
|
|
||||||
--restart unless-stopped \
|
|
||||||
--network host \
|
|
||||||
-e PXEFORGE_PUBLIC_IP=10.0.0.5 \
|
|
||||||
-e PXEFORGE_DHCP_MODE=proxy \
|
|
||||||
-v ~/pxeforge/isos:/var/lib/pxeforge/isos \
|
|
||||||
-v ~/pxeforge/work:/var/lib/pxeforge/work \
|
|
||||||
ghcr.io/YOUR-ORG/pxeforge:0.2.0
|
|
||||||
```
|
|
||||||
|
|
||||||
Substitute your `PXEFORGE_PUBLIC_IP`, of course. If you’re building
|
|
||||||
from this repo instead of pulling, see the
|
|
||||||
[README quick start](../README.md#quick-start--mvp-container-recommended).
|
|
||||||
|
|
||||||
### Verify it’s alive
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsS http://10.0.0.5/healthz # → 200 ok
|
|
||||||
curl -fsS http://10.0.0.5/readyz # → 200 ready (iPXE binaries present)
|
|
||||||
curl -fsS http://10.0.0.5/api/status | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
If `/readyz` is **not** 200, your container is missing iPXE binaries.
|
|
||||||
Fix that before going further — clients have nothing to boot
|
|
||||||
otherwise. See [README — Container health probes](../README.md#container-health-probes).
|
|
||||||
|
|
||||||
### Check the listening ports
|
|
||||||
|
|
||||||
PXEForge holds three privileged UDP/TCP ports. From another shell on
|
|
||||||
the host:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo ss -lnup | grep -E ':(67|69|4011)\b' # DHCP proxy + TFTP
|
|
||||||
sudo ss -lntp | grep ':80\b' # HTTP UI / boot scripts
|
|
||||||
```
|
|
||||||
|
|
||||||
All four should be present. If port 67 is taken by `dnsmasq` or the
|
|
||||||
host’s own DHCP, stop that service or run PXEForge on a separate box —
|
|
||||||
two listeners on `:67` will fight.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Load the ISO
|
|
||||||
|
|
||||||
Two options. Pick one.
|
|
||||||
|
|
||||||
### 2a. Web UI upload (recommended for one-offs)
|
|
||||||
|
|
||||||
1. Open `http://10.0.0.5/` in a browser.
|
|
||||||
2. Sidebar → **Storage**.
|
|
||||||
3. Click **Upload ISO**, pick e.g. `ubuntu-24.04.1-live-server-amd64.iso`.
|
|
||||||
4. Wait for upload + introspection. The row turns into a card showing:
|
|
||||||
- Distro family (`debian_ubuntu`)
|
|
||||||
- Volume label
|
|
||||||
- Detected kernel/initrd paths (`/casper/vmlinuz`, `/casper/initrd`)
|
|
||||||
- File size and SHA-256
|
|
||||||
|
|
||||||
Big ISOs stream — there is no 2 GB limit, but expect upload to be
|
|
||||||
gated by your browser ↔ host link. The UI shows a progress bar; the
|
|
||||||
animated anvil on the Dashboard tab fires up while imaging is in
|
|
||||||
flight.
|
|
||||||
|
|
||||||
### 2b. Bulk seed from a directory (recommended for fresh deploys / CI)
|
|
||||||
|
|
||||||
If you already have a folder of ISOs on the host, skip the browser:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Dry run first — see what would be imported, no writes:
|
|
||||||
docker exec pxeforge pxeforge seed \
|
|
||||||
--from /seed \
|
|
||||||
--dry-run
|
|
||||||
|
|
||||||
# For real, mount the source dir read-only into the container:
|
|
||||||
docker run --rm \
|
|
||||||
-v /my/iso-library:/seed:ro \
|
|
||||||
-v ~/pxeforge/isos:/var/lib/pxeforge/isos \
|
|
||||||
-v ~/pxeforge/work:/var/lib/pxeforge/work \
|
|
||||||
-e PXEFORGE_PUBLIC_IP=10.0.0.5 \
|
|
||||||
ghcr.io/YOUR-ORG/pxeforge:0.2.0 seed --from /seed
|
|
||||||
```
|
|
||||||
|
|
||||||
Each `*.iso` in `/seed` runs through the same upload pipeline as the
|
|
||||||
web UI: copy → introspection → boot-entry generation → metadata
|
|
||||||
sidecar. Re-running is idempotent.
|
|
||||||
|
|
||||||
### Confirm the ISO is registered
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsS http://10.0.0.5/api/isos | jq '.[] | {id, name, family, size}'
|
|
||||||
```
|
|
||||||
|
|
||||||
You should see something like:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "ubuntu-24-04-1-live-server-amd64",
|
|
||||||
"name": "ubuntu-24.04.1-live-server-amd64.iso",
|
|
||||||
"family": "debian_ubuntu",
|
|
||||||
"size": 2748000000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `id` is the **slug**. Remember it — you’ll bind a MAC to it in
|
|
||||||
the next step.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Find the target machine’s MAC address
|
|
||||||
|
|
||||||
You need the MAC of the **NIC that will PXE**, not the OS’s
|
|
||||||
loopback or wifi.
|
|
||||||
|
|
||||||
### 3a. From the target itself (if it’s already running an OS)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ip -o link | awk '/ether/ {print $2, $17}' # Linux
|
|
||||||
```
|
|
||||||
|
|
||||||
Pick the line for the wired NIC plugged into the PXE VLAN.
|
|
||||||
|
|
||||||
### 3b. From the firmware (if it’s a fresh box)
|
|
||||||
|
|
||||||
Most BIOS/UEFI screens display the NIC MAC during the network-boot
|
|
||||||
attempt — usually as `MAC: AA-BB-CC-DD-EE-FF` flashing on the splash
|
|
||||||
right before "PXE-E53: No boot filename received". Write it down.
|
|
||||||
|
|
||||||
### 3c. By letting it boot once and watching PXEForge
|
|
||||||
|
|
||||||
Easiest if the box is in front of you:
|
|
||||||
|
|
||||||
1. Power on, hit `F12`, pick **Network boot**.
|
|
||||||
2. Without any binding configured, the client will land on the
|
|
||||||
PXEForge menu (Default / Installers / Tools / Gated Deployment).
|
|
||||||
3. Don’t pick anything. On your laptop:
|
|
||||||
```bash
|
|
||||||
curl -fsS http://10.0.0.5/api/clients | jq .
|
|
||||||
```
|
|
||||||
4. The most-recent entry is your target. Copy its `mac`.
|
|
||||||
|
|
||||||
From here on we call this MAC `TARGET_MAC` (e.g. `aa:bb:cc:dd:ee:ff`).
|
|
||||||
Hyphens vs colons, upper vs lower case — PXEForge normalizes both.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Pin that machine to the Ubuntu ISO
|
|
||||||
|
|
||||||
This is the **per-MAC host binding**. With it set, the client won’t
|
|
||||||
see the menu at all — it goes straight to the bound boot entry,
|
|
||||||
Tinkerbell-style.
|
|
||||||
|
|
||||||
### 4a. Via the web UI
|
|
||||||
|
|
||||||
1. Sidebar → **Hosts**.
|
|
||||||
2. **Add binding**:
|
|
||||||
- **MAC**: `aa:bb:cc:dd:ee:ff`
|
|
||||||
- **Target**: pick `ubuntu-24-04-1-live-server-amd64` from the dropdown.
|
|
||||||
- **Label**: free-form, e.g. `lab-rack3-node07`.
|
|
||||||
3. Save.
|
|
||||||
|
|
||||||
### 4b. Via the API
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsS -X POST http://10.0.0.5/api/hosts \
|
|
||||||
-H 'content-type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"mac": "aa:bb:cc:dd:ee:ff",
|
|
||||||
"target": "ubuntu-24-04-1-live-server-amd64",
|
|
||||||
"label": "lab-rack3-node07"
|
|
||||||
}' | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
The binding is persisted to `~/pxeforge/work/hosts.json` and survives
|
|
||||||
container restart.
|
|
||||||
|
|
||||||
### Confirm
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsS http://10.0.0.5/api/hosts | jq '.[] | select(.mac=="aa:bb:cc:dd:ee:ff")'
|
|
||||||
```
|
|
||||||
|
|
||||||
You should see your entry with `created_at` and `updated_at`
|
|
||||||
timestamps.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Trigger the network boot on the target
|
|
||||||
|
|
||||||
Now actually boot the machine.
|
|
||||||
|
|
||||||
### 5a. Boot order
|
|
||||||
|
|
||||||
In firmware setup, set the wired NIC as the **first** boot device
|
|
||||||
(or hold `F12` / `F9` / `Esc` — vendor-specific — to pick "Network
|
|
||||||
Boot" interactively).
|
|
||||||
|
|
||||||
### 5b. What you should see on the target screen
|
|
||||||
|
|
||||||
In order, with timing:
|
|
||||||
|
|
||||||
| Stage | Approximate duration | What appears |
|
|
||||||
|-------|---------------------:|--------------|
|
|
||||||
| Firmware DHCPDISCOVER | ~1 s | `Start PXE over IPv4` / `Station IP address …` |
|
|
||||||
| TFTP iPXE binary fetch | ~1 s | `TFTP… snponly.efi` (or `undionly.kpxe` for legacy BIOS) |
|
|
||||||
| iPXE banner | ~1 s | The blue iPXE splash, version string |
|
|
||||||
| iPXE second-stage DHCP | ~1 s | `Configuring (net0 …)` then `ok` |
|
|
||||||
| HTTP boot script fetch | <1 s | `http://10.0.0.5/boot.ipxe?mac=…` |
|
|
||||||
| Per-MAC chain | <1 s | `PXEForge: per-MAC binding -> ubuntu-24-04-1-…` |
|
|
||||||
| Kernel + initrd HTTP | 5–30 s | Two 200-OK fetches against `/iso/<id>/casper/vmlinuz` and `…/initrd` |
|
|
||||||
| Kernel boot | 5–10 s | Kernel banner, then the Ubuntu/cloud-init splash |
|
|
||||||
| Installer comes up | 30–60 s | The distro’s normal Live/installer environment |
|
|
||||||
|
|
||||||
If everything works, you’re looking at the Ubuntu Server installer
|
|
||||||
welcome screen end-to-end **without ever touching a USB stick**.
|
|
||||||
|
|
||||||
### 5c. Watch it from the server
|
|
||||||
|
|
||||||
In a third shell, tail the live log:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -N http://10.0.0.5/api/log/stream
|
|
||||||
```
|
|
||||||
|
|
||||||
You’ll see each protocol step as it happens:
|
|
||||||
|
|
||||||
```
|
|
||||||
INFO pxeforge::dhcp: reply mac=aa:bb:cc:dd:ee:ff arch=X8664Uefi target=tftp/snponly.efi
|
|
||||||
INFO pxeforge::tftp: RRQ snponly.efi blksize=1468 windowsize=8 → 982 KiB in 412 ms
|
|
||||||
INFO pxeforge::dhcp: reply mac=aa:bb:cc:dd:ee:ff (iPXE) target=http/boot.ipxe
|
|
||||||
INFO pxeforge::http: GET /boot.ipxe?mac=aa:bb:cc:dd:ee:ff → host binding hit
|
|
||||||
INFO pxeforge::http: GET /iso/ubuntu-…/casper/vmlinuz Range=bytes=0- 200 OK 14 MiB
|
|
||||||
INFO pxeforge::http: GET /iso/ubuntu-…/casper/initrd Range=bytes=0- 200 OK 75 MiB
|
|
||||||
```
|
|
||||||
|
|
||||||
The **Terminal** tab in the web UI shows the same thing live, plus a
|
|
||||||
short whitelisted command palette (`status`, `clients`, `gate`,
|
|
||||||
`hosts`, `log`).
|
|
||||||
|
|
||||||
### 5d. Internet-side ISO sources
|
|
||||||
|
|
||||||
The runbook title says “via the internet” — the **client** itself
|
|
||||||
boots from your LAN, but the underlying ISO can come from anywhere
|
|
||||||
your *host* can reach:
|
|
||||||
|
|
||||||
- **Direct upload** from a remote workstation via the web UI (HTTPS
|
|
||||||
reverse-proxied if you put PXEForge behind nginx/Caddy).
|
|
||||||
- **NFS mount** of a remote share — Sidebar → **Storage** → **NFS** →
|
|
||||||
`nfs://files.lab.example.com/exports/isos`. Mounted ISOs show up in
|
|
||||||
the same list and are PXE-bootable directly without copying.
|
|
||||||
- **Pre-seed** from a CI job that `curl`s a vendor mirror and runs
|
|
||||||
`pxeforge seed --from`.
|
|
||||||
|
|
||||||
PXEForge itself never reaches out to the internet at boot time — all
|
|
||||||
client traffic stays on the LAN, served from the host.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. After the install
|
|
||||||
|
|
||||||
Once Ubuntu has finished installing to the target’s disk, you want
|
|
||||||
the next reboot to come up off the new local disk, **not** PXE
|
|
||||||
again. Two ways:
|
|
||||||
|
|
||||||
### 6a. One-shot — release the binding
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsS -X DELETE http://10.0.0.5/api/hosts/aa:bb:cc:dd:ee:ff
|
|
||||||
```
|
|
||||||
|
|
||||||
Without a binding, the client either gets the menu (BIOS still set
|
|
||||||
to PXE first) or boots local disk normally.
|
|
||||||
|
|
||||||
### 6b. Permanent — pin to local disk
|
|
||||||
|
|
||||||
Re-bind to the reserved local-boot target:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsS -X POST http://10.0.0.5/api/hosts \
|
|
||||||
-H 'content-type: application/json' \
|
|
||||||
-d '{ "mac": "aa:bb:cc:dd:ee:ff", "target": "_local", "label": "lab-rack3-node07 (installed)" }'
|
|
||||||
```
|
|
||||||
|
|
||||||
Now if anyone hits `F12 → Network` by accident, PXEForge replies
|
|
||||||
with a script that says *"chain back to local HDD"* and the box
|
|
||||||
boots its real OS instead of re-imaging itself. This is the safest
|
|
||||||
default for production hardware.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Re-imaging — the “Gated Deployment” flow
|
|
||||||
|
|
||||||
Different scenario: you have **a rack of 30 servers** to image
|
|
||||||
identically, all at once. Don’t bind 30 MACs by hand. Use the gate.
|
|
||||||
|
|
||||||
1. **Don’t** create host bindings.
|
|
||||||
2. PXE-boot every machine. They land on the menu.
|
|
||||||
3. On each: select **Gated Deployment**. They get position #1, #2,
|
|
||||||
…, #30 and start long-polling.
|
|
||||||
4. In the UI: **Forge Gate** tab shows all 30 lined up. Pick the
|
|
||||||
ISO, click **Assign to all waiting**.
|
|
||||||
5. Every client’s open long-poll wakes up at the same instant and
|
|
||||||
chains the same boot script. They all start imaging
|
|
||||||
simultaneously — the “horse race gate” opens.
|
|
||||||
|
|
||||||
The animated anvil widget on the Dashboard runs while any client is
|
|
||||||
still in the kernel-fetch phase.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cheat sheet
|
|
||||||
|
|
||||||
| Goal | Command |
|
|
||||||
|------|---------|
|
|
||||||
| Health check | `curl http://$IP/healthz` |
|
|
||||||
| List ISOs | `curl http://$IP/api/isos \| jq .` |
|
|
||||||
| List clients seen | `curl http://$IP/api/clients \| jq .` |
|
|
||||||
| Bind MAC → ISO | `POST /api/hosts` with `{mac,target,label}` |
|
|
||||||
| Bind MAC → local disk | same with `target=_local` |
|
|
||||||
| Release binding | `DELETE /api/hosts/<mac>` |
|
|
||||||
| Live log | `curl -N http://$IP/api/log/stream` |
|
|
||||||
| Prometheus metrics | `curl http://$IP/metrics` |
|
|
||||||
| Bulk import folder | `pxeforge seed --from /path` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Where to look when things break
|
|
||||||
|
|
||||||
- **Client gets `PXE-E53: No boot filename received`** — DHCP proxy
|
|
||||||
isn’t replying. Check `:67` is bound (`ss -lnup`), check
|
|
||||||
`--network host`, check the host firewall on UDP 67/69/4011.
|
|
||||||
- **iPXE shows `No more network devices`** — firmware NIC isn’t in
|
|
||||||
PXE mode, or VLAN tagging is wrong.
|
|
||||||
- **iPXE prints `Connection timed out (http://…)`** — `PXEFORGE_PUBLIC_IP`
|
|
||||||
is wrong. Clients can’t reach that IP. Check `/api/status` →
|
|
||||||
`public_base_url` and `ping` it from the client subnet.
|
|
||||||
- **Kernel panics during initrd load** — corrupt ISO upload. Check
|
|
||||||
`/api/isos`, compare the SHA-256 to the vendor’s, re-upload.
|
|
||||||
- **Boot menu shows but the bound entry doesn’t fire** — the binding
|
|
||||||
target slug doesn’t match any ISO `id`. Recheck
|
|
||||||
`GET /api/hosts` against `GET /api/isos`. The binding falls back
|
|
||||||
to the menu on miss (by design — never lock a client out).
|
|
||||||
- **General confusion** — Terminal tab → `status`, then `log`. That
|
|
||||||
tells you what protocol stages have run and which haven’t.
|
|
||||||
|
|
||||||
For deeper protocol-level debugging, see
|
|
||||||
[docs/architecture.md](../docs/architecture.md).
|
|
||||||
Reference in New Issue
Block a user