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