From 7adf5e2918e17c727a4ce2965e5974c1a882de4d Mon Sep 17 00:00:00 2001 From: Miles Ward Date: Sun, 31 May 2026 16:11:04 -0400 Subject: [PATCH] v0.5.2: FleetDM login split, 3-slot branding, unattended installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authentication / login: - Separate the local username/password form from the SSO "Sign in with …" button (FleetDM-style divider + optional IdP logo); credential fields no longer double as the SSO trigger. Settings → SSO copy now says SAML is live. Branding — three slots (light / dark / client) on one row: - Light/Dark feed the top-left mark + sign-in page by active theme (with cross-theme fallback; theme toggle swaps the logo live). Client feeds the PXE boot-menu background. Favicon pinned to the bundled mark via a new /assets/favicon.svg endpoint. Legacy single logo migrates to dark + client. - BrandingStore refactored to per-slot storage; /api/branding/logo/:slot. Unattended installs (Storage → Advanced): - New UnattendedStore (iso-store) + /api/unattended upload/list/delete and a public templated serve at /unattended/:id (+ NoCloud seed dir for autoinstall). Accepts .ks/.cfg/.seed/.yaml/.yml/.xml/user-data; classified on upload; stored in its own unattended/ dir, never the ISO listing/menu. - {{HOSTNAME}}/{{IP}}/{{MAC}} substituted per host at serve time. Host pins + Queue profiles: - HostBinding + QueueEntry carry an optional DeployProfile (auto_hostname / auto_ip / unattended_file). Hosts pin form + a per-device Queue "Profile" button collect them. On boot, a matched MAC has the right kernel arg injected (inst.ks= / preseed url= / autoinstall ds=nocloud-net) and the hostname/IP templated into the served answer file. DHCP stays proxy-only. Storage: - Remote shares default protocol is now NFS; updated descriptive copy. 235 tests green, clippy clean. Still a single static musl binary, pure Rust. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 16 +- Cargo.toml | 2 +- crates/core/src/branding.rs | 584 +++++++++++++++++++++-------- crates/core/src/config.rs | 6 + crates/core/src/host_bindings.rs | 74 +++- crates/core/src/lib.rs | 4 +- crates/core/src/profile.rs | 122 ++++++ crates/core/src/queue.rs | 32 ++ crates/http-api/src/app.rs | 553 +++++++++++++++++++++++++-- crates/http-api/src/auth.rs | 2 +- crates/http-api/src/ipxe_script.rs | 21 +- crates/http-api/src/state.rs | 7 +- crates/http-api/tests/full_flow.rs | 226 ++++++++++- crates/iso-store/src/lib.rs | 8 +- crates/iso-store/src/unattended.rs | 412 ++++++++++++++++++++ crates/openpxe/src/main.rs | 11 + crates/webui/src/app.css | 64 ++++ crates/webui/src/app.js | 476 +++++++++++++++++------ crates/webui/src/index.html | 5 +- 19 files changed, 2295 insertions(+), 330 deletions(-) create mode 100644 crates/core/src/profile.rs create mode 100644 crates/iso-store/src/unattended.rs diff --git a/Cargo.lock b/Cargo.lock index 348b5d7..57e5796 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2251,7 +2251,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openpxe" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anyhow", "axum", @@ -2273,7 +2273,7 @@ dependencies = [ [[package]] name = "openpxe-core" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anyhow", "base64", @@ -2299,7 +2299,7 @@ dependencies = [ [[package]] name = "openpxe-dhcp-proxy" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anyhow", "bytes", @@ -2313,7 +2313,7 @@ dependencies = [ [[package]] name = "openpxe-http-api" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anyhow", "axum", @@ -2349,7 +2349,7 @@ dependencies = [ [[package]] name = "openpxe-ipxe-assets" -version = "0.5.1" +version = "0.5.2" dependencies = [ "openpxe-core", "rust-embed", @@ -2359,7 +2359,7 @@ dependencies = [ [[package]] name = "openpxe-iso-store" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anyhow", "bcrypt", @@ -2386,7 +2386,7 @@ dependencies = [ [[package]] name = "openpxe-tftp" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anyhow", "bytes", @@ -2400,7 +2400,7 @@ dependencies = [ [[package]] name = "openpxe-webui" -version = "0.5.1" +version = "0.5.2" [[package]] name = "p256" diff --git a/Cargo.toml b/Cargo.toml index f6c833f..dfa8d42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.5.1" +version = "0.5.2" edition = "2021" rust-version = "1.95" license = "MIT OR Apache-2.0" diff --git a/crates/core/src/branding.rs b/crates/core/src/branding.rs index 0b98939..c0cc745 100644 --- a/crates/core/src/branding.rs +++ b/crates/core/src/branding.rs @@ -1,11 +1,23 @@ //! Operator-controlled branding overrides. //! -//! The browser tab's logo (`/assets/logo.svg`) defaults to the bundled -//! rainbow-horizon mark. Operators who deploy OpenPXE behind their own -//! branding can upload a replacement that lives at -//! `/branding/logo.` and is served in preference to the -//! bundled SVG when present. Borrowed-from-FleetDM: tenant chrome, same -//! product. +//! v0.5.2 splits the single brand mark into **three independent slots**, +//! FleetDM-style: +//! +//! * `light` — shown in the WebUI top-left and on the form-login page +//! when the active theme is light. +//! * `dark` — same surfaces, when the active theme is dark. +//! * `client` — the raster painted above the iPXE boot menu entries +//! (`/branding/pxe-logo`), i.e. what a PXE client sees on the screen. +//! +//! Each slot lives at `/branding/logo-.` and is +//! served in preference to the bundled rainbow-horizon mark when present. +//! Borrowed-from-FleetDM: tenant chrome, same product. +//! +//! Legacy continuity: a pre-v0.5.2 single `logo.` (recorded under +//! the old `logo_filename`/`logo_mime` keys) is migrated on first load +//! into both the `dark` and `client` slots — that preserves the previous +//! behaviour (one mark fed both the dark WebUI and the PXE screen) until +//! the operator uploads dedicated variants. //! //! Storage policy mirrors `HostBindings` / `BootLog`: in-memory cache is //! authoritative for the current process, disk is the source of truth on @@ -35,27 +47,104 @@ pub const ALLOWED_LOGO_MIMES: &[&str] = &[ /// puts a clear bound on memory + serialization cost. pub const MAX_LOGO_BYTES: usize = 2 * 1024 * 1024; +/// Which branded surface a logo upload targets. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogoSlot { + /// WebUI + form-login page, light theme. + Light, + /// WebUI + form-login page, dark theme. + Dark, + /// iPXE boot-menu background seen by PXE clients. + Client, +} + +impl LogoSlot { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + LogoSlot::Light => "light", + LogoSlot::Dark => "dark", + LogoSlot::Client => "client", + } + } + + /// Parse a slot name from the URL path segment. Case-insensitive. + #[must_use] + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "light" => Some(LogoSlot::Light), + "dark" => Some(LogoSlot::Dark), + "client" => Some(LogoSlot::Client), + _ => None, + } + } +} + +/// One brand-mark slot: a filename (relative to the branding dir) plus +/// the MIME we cached at upload time so the HTTP layer can set the +/// Content-Type without re-sniffing. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct Slot { + #[serde(default, skip_serializing_if = "Option::is_none")] + filename: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + mime: Option, +} + +impl Slot { + fn clear_file(&mut self, dir: &Path) { + if let Some(name) = self.filename.take() { + let _ = std::fs::remove_file(dir.join(name)); + } + self.mime = None; + } +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] struct Inner { - /// File name (relative to the branding dir) for the active logo, if - /// any. Always under `/branding/`; never an absolute path - /// from the operator. - logo_filename: Option, - /// MIME of the active logo, mirroring `logo_filename`. Cached here - /// so the HTTP layer can set Content-Type without re-sniffing. - logo_mime: Option, - /// Monotonic counter bumped on every set/clear. Surfaces as a - /// cache-bust token (`/assets/logo.svg?r=`) so the browser - /// fetches the new bytes the moment the operator swaps the logo — - /// the app version alone can't do this since it doesn't change on - /// upload. Persisted so the token stays stable across restarts and - /// keeps climbing across multiple swaps. + #[serde(default)] + light: Slot, + #[serde(default)] + dark: Slot, + #[serde(default)] + client: Slot, + /// Monotonic counter bumped on every set/clear (any slot). Surfaces + /// as a cache-bust token (`/assets/logo.svg?r=`) so the browser + /// fetches the new bytes the moment the operator swaps a logo — the + /// app version alone can't do this since it doesn't change on upload. + /// Persisted so the token stays stable across restarts and keeps + /// climbing across multiple swaps. #[serde(default)] rev: u64, + // ── Legacy (pre-v0.5.2) single-logo keys ────────────────────────── + // Read on load for one-way migration into `dark` + `client`, then + // dropped from the persisted form (skip_serializing_if). + #[serde(default, skip_serializing_if = "Option::is_none")] + logo_filename: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + logo_mime: Option, +} + +impl Inner { + fn slot(&self, slot: LogoSlot) -> &Slot { + match slot { + LogoSlot::Light => &self.light, + LogoSlot::Dark => &self.dark, + LogoSlot::Client => &self.client, + } + } + + fn slot_mut(&mut self, slot: LogoSlot) -> &mut Slot { + match slot { + LogoSlot::Light => &mut self.light, + LogoSlot::Dark => &mut self.dark, + LogoSlot::Client => &mut self.client, + } + } } /// In-memory + on-disk override registry. Cheap to clone; locks are -/// brief. The `branding.json` cache lives alongside the active asset +/// brief. The `branding.json` cache lives alongside the active assets /// inside `/branding/`. #[derive(Debug, Clone)] pub struct BrandingStore { @@ -67,7 +156,8 @@ pub struct BrandingStore { impl BrandingStore { /// Load (or initialise empty) from `/branding/`. Tolerates /// missing directories, partial state, and corrupt JSON — a bad - /// cache should never block PXE for the network. + /// cache should never block PXE for the network. Migrates a legacy + /// single-logo file into the dark + client slots. #[must_use] pub fn load_or_default(work_dir: &Path) -> Self { let dir = work_dir.join("branding"); @@ -75,25 +165,7 @@ impl BrandingStore { let mut inner = Inner::default(); if let Ok(text) = std::fs::read_to_string(&path) { match serde_json::from_str::(&text) { - Ok(parsed) => { - // Sanity: if the JSON says we have a logo but the - // file is gone, clear the in-memory pointer so - // /assets/logo.svg falls back to the bundled SVG - // rather than 500ing on a missing file. - if let Some(name) = parsed.logo_filename.as_deref() { - if dir.join(name).is_file() { - inner = parsed; - } else { - tracing::warn!( - target: "openpxe::branding", - file = %name, - "branding.json points at missing file; clearing" - ); - } - } else { - inner = parsed; - } - } + Ok(parsed) => inner = parsed, Err(e) => { tracing::warn!( target: "openpxe::branding", @@ -102,102 +174,242 @@ impl BrandingStore { } } } - Self { + let store = Self { dir: Arc::new(dir), inner: Arc::new(RwLock::new(inner)), + }; + store.migrate_legacy(); + store.prune_missing(); + store + } + + /// One-way migration: a pre-v0.5.2 `logo.` becomes the dark + + /// client slots (the old single mark fed both the dark WebUI and the + /// PXE screen). Best-effort; failures leave the legacy file in place + /// rather than blocking startup. + fn migrate_legacy(&self) { + let (legacy_name, legacy_mime) = { + let g = self.inner.read(); + (g.logo_filename.clone(), g.logo_mime.clone()) + }; + let Some(name) = legacy_name else { return }; + let src = self.dir.join(&name); + if !src.is_file() { + // Legacy pointer is stale — just drop it. + let mut g = self.inner.write(); + g.logo_filename = None; + g.logo_mime = None; + drop(g); + self.persist(); + return; } - } - - /// Absolute path to the active logo, if one is set and present on - /// disk. `None` means the HTTP layer should serve the bundled SVG. - #[must_use] - pub fn logo_path(&self) -> Option { - let g = self.inner.read(); - g.logo_filename.as_deref().map(|n| self.dir.join(n)) - } - - /// MIME of the active logo, if any. The HTTP layer pairs this with - /// the bytes returned by [`Self::logo_path`]. - #[must_use] - pub fn logo_mime(&self) -> Option { - self.inner.read().logo_mime.clone() - } - - /// Replace the active logo. Returns the chosen on-disk filename so - /// the caller can echo it back in the API response. Old logos are - /// removed best-effort. - pub fn set_logo(&self, mime: &str, ext: &str, bytes: &[u8]) -> std::io::Result { - std::fs::create_dir_all(self.dir.as_path())?; - // Single canonical filename per upload — overwriting the old one - // (after clearing it) keeps the directory tidy and avoids any - // path-traversal concern: the operator never supplies the name. - let safe_ext = sanitize_ext(ext); - let filename = format!("logo.{safe_ext}"); - let final_path = self.dir.join(&filename); - // Atomic write: tmp -> rename. Guarantees the file is either - // entirely the old logo or entirely the new one. - let tmp = final_path.with_extension(format!("{safe_ext}.tmp")); - std::fs::write(&tmp, bytes)?; - std::fs::rename(&tmp, &final_path)?; - // Clean up any sibling logo. so there's exactly one - // canonical file at any time. - if let Ok(entries) = std::fs::read_dir(self.dir.as_path()) { - for e in entries.flatten() { - let p = e.path(); - let name = p - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or(""); - if name.starts_with("logo.") && name != filename { - let _ = std::fs::remove_file(&p); - } + let mime = legacy_mime.unwrap_or_else(|| "image/svg+xml".to_string()); + let ext = ext_for_mime(&mime).unwrap_or("bin"); + if let Ok(bytes) = std::fs::read(&src) { + // Seed dark + client only when those slots are still empty so + // a re-run (or a manual edit) never clobbers operator intent. + let needs_dark = self.inner.read().dark.filename.is_none(); + let needs_client = self.inner.read().client.filename.is_none(); + if needs_dark { + let _ = self.write_slot(LogoSlot::Dark, &mime, ext, &bytes); + } + if needs_client { + let _ = self.write_slot(LogoSlot::Client, &mime, ext, &bytes); } } - + let _ = std::fs::remove_file(&src); { let mut g = self.inner.write(); - g.logo_filename = Some(filename.clone()); - g.logo_mime = Some(mime.to_string()); - g.rev = g.rev.wrapping_add(1); + g.logo_filename = None; + g.logo_mime = None; } self.persist(); tracing::info!( target: "openpxe::branding", - file = %filename, mime = %mime, size = bytes.len(), + "migrated legacy single logo into dark + client slots" + ); + } + + /// Drop in-memory slot pointers whose backing file vanished from disk + /// so the HTTP layer falls back to the bundled mark instead of 500ing. + fn prune_missing(&self) { + let mut changed = false; + { + let mut g = self.inner.write(); + for slot in [LogoSlot::Light, LogoSlot::Dark, LogoSlot::Client] { + let present = g + .slot(slot) + .filename + .as_deref() + .is_some_and(|n| self.dir.join(n).is_file()); + if !present && g.slot(slot).filename.is_some() { + g.slot_mut(slot).filename = None; + g.slot_mut(slot).mime = None; + changed = true; + } + } + } + if changed { + self.persist(); + } + } + + /// Absolute path to the logo for `slot`, if set and present on disk. + #[must_use] + pub fn slot_path(&self, slot: LogoSlot) -> Option { + let g = self.inner.read(); + g.slot(slot).filename.as_deref().map(|n| self.dir.join(n)) + } + + /// MIME of the logo for `slot`, if any. + #[must_use] + pub fn slot_mime(&self, slot: LogoSlot) -> Option { + self.inner.read().slot(slot).mime.clone() + } + + /// Resolve the WebUI logo for a theme, with fallback: light falls + /// back to dark and vice-versa, so a single uploaded variant still + /// shows on both themes. Returns `(path, mime)` or `None` (→ bundled). + #[must_use] + pub fn web_logo(&self, theme_is_light: bool) -> Option<(PathBuf, String)> { + let (primary, secondary) = if theme_is_light { + (LogoSlot::Light, LogoSlot::Dark) + } else { + (LogoSlot::Dark, LogoSlot::Light) + }; + let g = self.inner.read(); + let chosen = if g.slot(primary).filename.is_some() { + primary + } else { + secondary + }; + let s = g.slot(chosen); + s.filename.as_deref().map(|n| { + ( + self.dir.join(n), + s.mime + .clone() + .unwrap_or_else(|| "image/svg+xml".to_string()), + ) + }) + } + + /// Resolve the PXE client logo (no theme fallback — the PXE screen + /// has a single mark). Returns `(path, mime)` or `None` (→ default + /// composed background). + #[must_use] + pub fn client_logo(&self) -> Option<(PathBuf, String)> { + let g = self.inner.read(); + let s = &g.client; + s.filename.as_deref().map(|n| { + ( + self.dir.join(n), + s.mime + .clone() + .unwrap_or_else(|| "application/octet-stream".to_string()), + ) + }) + } + + /// Replace the logo for `slot`. Returns the chosen on-disk filename so + /// the caller can echo it back in the API response. + pub fn set_logo( + &self, + slot: LogoSlot, + mime: &str, + ext: &str, + bytes: &[u8], + ) -> std::io::Result { + let filename = self.write_slot(slot, mime, ext, bytes)?; + self.persist(); + tracing::info!( + target: "openpxe::branding", + slot = slot.as_str(), file = %filename, mime = %mime, size = bytes.len(), "custom logo installed" ); Ok(filename) } - /// Drop the override and return to the bundled SVG. - pub fn clear_logo(&self) -> std::io::Result<()> { - let removed = { + /// Write the bytes for a slot and update the in-memory pointer + rev, + /// without persisting (the caller decides when to flush). Cleans up + /// any sibling `logo-.*` so there's exactly one file per slot. + fn write_slot( + &self, + slot: LogoSlot, + mime: &str, + ext: &str, + bytes: &[u8], + ) -> std::io::Result { + std::fs::create_dir_all(self.dir.as_path())?; + let safe_ext = sanitize_ext(ext); + let stem = format!("logo-{}", slot.as_str()); + let filename = format!("{stem}.{safe_ext}"); + let final_path = self.dir.join(&filename); + // Atomic write: tmp -> rename. + let tmp = final_path.with_extension(format!("{safe_ext}.tmp")); + std::fs::write(&tmp, bytes)?; + std::fs::rename(&tmp, &final_path)?; + // Clean up any sibling `logo-.`. + if let Ok(entries) = std::fs::read_dir(self.dir.as_path()) { + for e in entries.flatten() { + let p = e.path(); + let name = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); + if name.starts_with(&format!("{stem}.")) && name != filename { + let _ = std::fs::remove_file(&p); + } + } + } + let mut g = self.inner.write(); + let s = g.slot_mut(slot); + s.filename = Some(filename.clone()); + s.mime = Some(mime.to_string()); + g.rev = g.rev.wrapping_add(1); + Ok(filename) + } + + /// Drop the override for `slot` and return to the bundled / default. + pub fn clear_logo(&self, slot: LogoSlot) -> std::io::Result<()> { + { let mut g = self.inner.write(); - let removed = g.logo_filename.take(); - g.logo_mime = None; + let dir = self.dir.as_path(); + g.slot_mut(slot).clear_file(dir); g.rev = g.rev.wrapping_add(1); - removed - }; - if let Some(name) = removed { - let p = self.dir.join(&name); - let _ = std::fs::remove_file(&p); - tracing::info!(target: "openpxe::branding", file = %name, "custom logo cleared"); } self.persist(); + tracing::info!(target: "openpxe::branding", slot = slot.as_str(), "custom logo cleared"); Ok(()) } - /// Convenience: true if a custom logo is configured. Surfaces on - /// `/api/status` so the WebUI can show "Custom logo: yes" without - /// fetching the asset itself. + /// True if a custom logo is configured for `slot`. #[must_use] - pub fn has_logo(&self) -> bool { - self.inner.read().logo_filename.is_some() + pub fn has_logo(&self, slot: LogoSlot) -> bool { + self.inner.read().slot(slot).filename.is_some() } - /// Cache-bust token for the logo asset URL. Changes on every + /// True if either WebUI theme slot has a custom logo — drives the + /// FleetDM-style full-width brand block (and the `has-custom-logo` + /// class) on the sidebar + login page. + #[must_use] + pub fn has_any_web_logo(&self) -> bool { + let g = self.inner.read(); + g.light.filename.is_some() || g.dark.filename.is_some() + } + + /// Presence triple `(light, dark, client)` for the `/api/me` and + /// `/api/status` bootstrap payloads. + #[must_use] + pub fn presence(&self) -> (bool, bool, bool) { + let g = self.inner.read(); + ( + g.light.filename.is_some(), + g.dark.filename.is_some(), + g.client.filename.is_some(), + ) + } + + /// Cache-bust token for the logo asset URLs. Changes on every /// set/clear so `/assets/logo.svg?r=` resolves to a fresh URL - /// whenever the operator swaps the brand mark. Stable otherwise. + /// whenever the operator swaps a brand mark. Stable otherwise. #[must_use] pub fn logo_rev(&self) -> u64 { self.inner.read().rev @@ -267,47 +479,87 @@ mod tests { fn empty_after_load_when_no_branding_dir() { let dir = tempdir().unwrap(); let b = BrandingStore::load_or_default(dir.path()); - assert!(!b.has_logo()); - assert!(b.logo_path().is_none()); - assert!(b.logo_mime().is_none()); + assert!(!b.has_logo(LogoSlot::Light)); + assert!(!b.has_logo(LogoSlot::Dark)); + assert!(!b.has_logo(LogoSlot::Client)); + assert!(b.web_logo(false).is_none()); + assert!(b.client_logo().is_none()); + assert!(!b.has_any_web_logo()); } #[test] fn set_clear_round_trip_persists() { let dir = tempdir().unwrap(); let b = BrandingStore::load_or_default(dir.path()); - let name = b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake").unwrap(); - assert_eq!(name, "logo.png"); - assert!(b.has_logo()); - assert_eq!(b.logo_mime().as_deref(), Some("image/png")); - let p = b.logo_path().unwrap(); + let name = b + .set_logo(LogoSlot::Dark, "image/png", "png", b"\x89PNG\r\n\x1a\nfake") + .unwrap(); + assert_eq!(name, "logo-dark.png"); + assert!(b.has_logo(LogoSlot::Dark)); + assert_eq!(b.slot_mime(LogoSlot::Dark).as_deref(), Some("image/png")); + let (p, _) = b.web_logo(false).unwrap(); assert!(p.is_file()); // Re-open and confirm the override survives a restart. drop(b); let b2 = BrandingStore::load_or_default(dir.path()); - assert!(b2.has_logo()); - assert_eq!(b2.logo_mime().as_deref(), Some("image/png")); + assert!(b2.has_logo(LogoSlot::Dark)); + assert_eq!(b2.slot_mime(LogoSlot::Dark).as_deref(), Some("image/png")); // Clear; the file goes away and has_logo flips off. - b2.clear_logo().unwrap(); - assert!(!b2.has_logo()); + b2.clear_logo(LogoSlot::Dark).unwrap(); + assert!(!b2.has_logo(LogoSlot::Dark)); assert!(!p.exists()); } #[test] - fn replacing_logo_removes_old_extension_sibling() { - // PNG then SVG; only the SVG should remain on disk. + fn web_logo_falls_back_across_themes() { let dir = tempdir().unwrap(); let b = BrandingStore::load_or_default(dir.path()); - b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake").unwrap(); - b.set_logo("image/svg+xml", "svg", br#""#).unwrap(); + // Only dark uploaded — light theme falls back to it. + b.set_logo(LogoSlot::Dark, "image/png", "png", b"dark") + .unwrap(); + let (p_light, _) = b.web_logo(true).expect("light falls back to dark"); + assert!(p_light.ends_with("logo-dark.png")); + // Upload a distinct light — now light theme uses its own. + b.set_logo(LogoSlot::Light, "image/png", "png", b"light") + .unwrap(); + let (p_light2, _) = b.web_logo(true).unwrap(); + assert!(p_light2.ends_with("logo-light.png")); + // Client is independent and still unset. + assert!(b.client_logo().is_none()); + } + + #[test] + fn replacing_slot_removes_old_extension_sibling() { + let dir = tempdir().unwrap(); + let b = BrandingStore::load_or_default(dir.path()); + b.set_logo( + LogoSlot::Client, + "image/png", + "png", + b"\x89PNG\r\n\x1a\nfake", + ) + .unwrap(); + b.set_logo( + LogoSlot::Client, + "image/svg+xml", + "svg", + br#""#, + ) + .unwrap(); let entries: Vec<_> = std::fs::read_dir(dir.path().join("branding")) .unwrap() .filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned())) .collect(); - assert!(entries.iter().any(|n| n == "logo.svg"), "got {entries:?}"); - assert!(!entries.iter().any(|n| n == "logo.png"), "stale PNG left over: {entries:?}"); + assert!( + entries.iter().any(|n| n == "logo-client.svg"), + "got {entries:?}" + ); + assert!( + !entries.iter().any(|n| n == "logo-client.png"), + "stale PNG left over: {entries:?}" + ); } #[test] @@ -315,54 +567,92 @@ mod tests { let dir = tempdir().unwrap(); let b = BrandingStore::load_or_default(dir.path()); assert_eq!(b.logo_rev(), 0); - b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake").unwrap(); + b.set_logo(LogoSlot::Light, "image/png", "png", b"a") + .unwrap(); assert_eq!(b.logo_rev(), 1); - b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake2").unwrap(); + b.set_logo(LogoSlot::Dark, "image/png", "png", b"b") + .unwrap(); assert_eq!(b.logo_rev(), 2); - b.clear_logo().unwrap(); + b.clear_logo(LogoSlot::Light).unwrap(); assert_eq!(b.logo_rev(), 3); - // Survives a restart. drop(b); let b2 = BrandingStore::load_or_default(dir.path()); assert_eq!(b2.logo_rev(), 3); } + #[test] + fn legacy_single_logo_migrates_to_dark_and_client() { + // A pre-v0.5.2 branding.json + logo.png migrates on load. + let dir = tempdir().unwrap(); + let brand_dir = dir.path().join("branding"); + std::fs::create_dir_all(&brand_dir).unwrap(); + std::fs::write(brand_dir.join("logo.png"), b"\x89PNG\r\n\x1a\nlegacy").unwrap(); + // Hand-write the old shape (logo_filename/logo_mime, no slots). + std::fs::write( + brand_dir.join("branding.json"), + br#"{"logo_filename":"logo.png","logo_mime":"image/png","rev":4}"#, + ) + .unwrap(); + let b = BrandingStore::load_or_default(dir.path()); + assert!(b.has_logo(LogoSlot::Dark), "dark seeded from legacy"); + assert!(b.has_logo(LogoSlot::Client), "client seeded from legacy"); + assert!(!b.has_logo(LogoSlot::Light), "light stays empty"); + // The old logo.png is gone; per-slot files exist. + assert!(!brand_dir.join("logo.png").exists()); + assert!(brand_dir.join("logo-dark.png").is_file()); + assert!(brand_dir.join("logo-client.png").is_file()); + // rev carried over from the legacy file and advanced as the two + // slots were seeded (each write bumps it), so it never regresses. + let migrated_rev = b.logo_rev(); + assert!( + migrated_rev >= 4, + "rev should not regress below legacy: {migrated_rev}" + ); + // And the migration is sticky across a restart (no re-migrate, no + // further rev churn). + drop(b); + let b2 = BrandingStore::load_or_default(dir.path()); + assert!(b2.has_logo(LogoSlot::Dark)); + assert!(b2.has_logo(LogoSlot::Client)); + assert!(!b2.has_logo(LogoSlot::Light)); + assert_eq!(b2.logo_rev(), migrated_rev, "restart must not re-migrate"); + } + #[test] fn sanitize_ext_strips_separators_and_path_chars() { assert_eq!(sanitize_ext("svg"), "svg"); - // Path separators and non-alphanumerics filter out, leaving just - // letters. The remaining "etcpasswd" exceeds the 5-char cap so - // it collapses to `bin` rather than producing `etcpa`. assert_eq!(sanitize_ext("../etc/passwd"), "bin"); - // Short alphanumeric strip-through stays itself. assert_eq!(sanitize_ext("../svg"), "svg"); assert_eq!(sanitize_ext(""), "bin"); assert_eq!(sanitize_ext("PNG"), "png"); - // Anything past five chars is suspicious — collapse to `bin`. assert_eq!(sanitize_ext("svgvvvv"), "bin"); } #[test] fn missing_file_referenced_by_json_resolves_to_empty() { - // If the operator nukes the file out from under the JSON cache, - // we should silently fall back to no-override rather than - // hanging on to a bogus path. let dir = tempdir().unwrap(); let brand_dir = dir.path().join("branding"); std::fs::create_dir_all(&brand_dir).unwrap(); - // Hand-write a branding.json claiming logo.png exists. - let inner = Inner { - logo_filename: Some("logo.png".into()), - logo_mime: Some("image/png".into()), - rev: 0, - }; + // branding.json claims a dark slot whose file doesn't exist. std::fs::write( brand_dir.join("branding.json"), - serde_json::to_vec_pretty(&inner).unwrap(), + br#"{"dark":{"filename":"logo-dark.png","mime":"image/png"},"rev":1}"#, ) .unwrap(); let b = BrandingStore::load_or_default(dir.path()); - assert!(!b.has_logo(), "should fall back when referenced file is missing"); + assert!( + !b.has_logo(LogoSlot::Dark), + "should fall back when referenced file is missing" + ); + } + + #[test] + fn slot_parse_round_trips() { + assert_eq!(LogoSlot::parse("light"), Some(LogoSlot::Light)); + assert_eq!(LogoSlot::parse("DARK"), Some(LogoSlot::Dark)); + assert_eq!(LogoSlot::parse(" client "), Some(LogoSlot::Client)); + assert_eq!(LogoSlot::parse("nope"), None); + assert_eq!(LogoSlot::Light.as_str(), "light"); } #[test] diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 044843a..ea88e4c 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -74,6 +74,11 @@ pub struct Paths { /// Only used when `settings.windows_enabled = true`. Defaults to /// `/var/lib/openpxe/smb` in the container image. pub smb_dir: PathBuf, + /// v0.5.2: directory holding uploaded unattended-install answer files + /// (Kickstart / Preseed / Autoinstall / Windows answer files). Kept + /// separate from `iso_dir` so answer files never appear in the ISO + /// listing or the PXE menu. Defaults to `/var/lib/openpxe/unattended`. + pub unattended_dir: PathBuf, } impl Default for ServerConfig { @@ -109,6 +114,7 @@ impl Default for Paths { ipxe_dir: PathBuf::from("/usr/share/openpxe/ipxe"), wimboot_path: None, smb_dir: PathBuf::from("/var/lib/openpxe/smb"), + unattended_dir: PathBuf::from("/var/lib/openpxe/unattended"), } } } diff --git a/crates/core/src/host_bindings.rs b/crates/core/src/host_bindings.rs index 9e7be03..8acab00 100644 --- a/crates/core/src/host_bindings.rs +++ b/crates/core/src/host_bindings.rs @@ -21,6 +21,8 @@ use std::path::PathBuf; use std::sync::Arc; use time::OffsetDateTime; +use crate::profile::DeployProfile; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HostBinding { /// Lowercase, colon-separated MAC (e.g. `aa:bb:cc:dd:ee:ff`). The @@ -35,6 +37,11 @@ pub struct HostBinding { /// `"rack-3 spine"`). Empty if unset. #[serde(default)] pub label: String, + /// v0.5.2: optional unattended-install hints (auto hostname / IP / + /// answer-file id). Flattened into the binding JSON so pre-v0.5.2 + /// `hosts.json` files (which lack these keys) still deserialize. + #[serde(default, flatten)] + pub profile: DeployProfile, #[serde(with = "time::serde::rfc3339")] pub created_at: OffsetDateTime, #[serde(with = "time::serde::rfc3339")] @@ -94,20 +101,31 @@ impl HostBindings { } /// Insert or update. Returns the resulting binding (with timestamps). - pub fn upsert(&self, mac: &str, target: &str, label: &str) -> HostBinding { + /// The `profile` carries optional unattended-install hints (v0.5.2); + /// pass `DeployProfile::default()` for a plain pin. + pub fn upsert( + &self, + mac: &str, + target: &str, + label: &str, + profile: DeployProfile, + ) -> HostBinding { let key = normalize_mac(mac); let now = OffsetDateTime::now_utc(); + let profile = profile.normalized(); 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(), + profile: profile.clone(), created_at: now, updated_at: now, }); entry.target = target.to_string(); entry.label = label.to_string(); + entry.profile = profile.clone(); entry.updated_at = now; entry.clone() }; @@ -179,6 +197,10 @@ mod tests { use super::*; use tempfile::tempdir; + fn np() -> DeployProfile { + DeployProfile::default() + } + #[test] fn normalize_handles_case_and_dashes() { assert_eq!(normalize_mac("AA:BB:CC:DD:EE:FF"), "aa:bb:cc:dd:ee:ff"); @@ -191,7 +213,12 @@ mod tests { 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"); + h.upsert( + "AA:BB:CC:00:00:01", + "ubuntu-24-04-linux", + "rack-3 spine", + np(), + ); 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"); @@ -202,8 +229,8 @@ mod tests { 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"); + h.upsert("aa:bb:cc:00:00:01", "old-target", "label1", np()); + h.upsert("aa:bb:cc:00:00:01", "new-target", "label2", np()); assert_eq!(h.len(), 1); let b = h.lookup("aa:bb:cc:00:00:01").unwrap(); assert_eq!(b.target, "new-target"); @@ -214,7 +241,7 @@ mod tests { 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", ""); + h.upsert("aa:bb:cc:00:00:01", "x", "", np()); assert!(h.remove("AA:BB:CC:00:00:01")); assert!(!h.remove("aa:bb:cc:00:00:01")); // already gone assert!(h.is_empty()); @@ -224,11 +251,44 @@ mod tests { 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"); + h.upsert("aa:bb:cc:00:00:01", "ubuntu-linux", "rack-3", np()); + h.upsert("aa:bb:cc:00:00:02", "_local", "tom-laptop", np()); 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"); } + + #[test] + fn profile_round_trips_to_disk() { + let dir = tempdir().unwrap(); + let h = HostBindings::load_or_default(dir.path()); + let prof = DeployProfile { + auto_hostname: Some("node-7".into()), + auto_ip: Some("10.0.0.7".into()), + unattended_file: Some("ubuntu-ks".into()), + }; + h.upsert("aa:bb:cc:00:00:09", "ubuntu-linux", "lab", prof); + drop(h); + let h2 = HostBindings::load_or_default(dir.path()); + let b = h2.lookup("aa:bb:cc:00:00:09").unwrap(); + assert_eq!(b.profile.auto_hostname.as_deref(), Some("node-7")); + assert_eq!(b.profile.auto_ip.as_deref(), Some("10.0.0.7")); + assert_eq!(b.profile.unattended_file.as_deref(), Some("ubuntu-ks")); + } + + #[test] + fn legacy_hosts_json_without_profile_still_loads() { + // A pre-v0.5.2 hosts.json has no profile keys at all. + let dir = tempdir().unwrap(); + std::fs::write( + dir.path().join("hosts.json"), + br#"[{"mac":"aa:bb:cc:00:00:01","target":"_local","label":"old","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}]"#, + ) + .unwrap(); + let h = HostBindings::load_or_default(dir.path()); + let b = h.lookup("aa:bb:cc:00:00:01").unwrap(); + assert_eq!(b.target, "_local"); + assert!(b.profile.is_empty()); + } } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 2e39e0a..f25d3a3 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -13,6 +13,7 @@ pub mod host_bindings; pub mod log_bus; pub mod metrics; pub mod notify; +pub mod profile; pub mod queue; pub mod saml; pub mod settings; @@ -22,7 +23,7 @@ pub mod wol; pub use arch::{ClientArch, FirmwareClass}; pub use auth::{AdminAccount, AdminPublic, AdminStore}; pub use boot_log::{BootEvent, BootLog}; -pub use branding::{ext_for_mime, BrandingStore, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES}; +pub use branding::{ext_for_mime, BrandingStore, LogoSlot, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES}; pub use client::{ClientEvent, ClientRegistry, ClientSnapshot}; pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig}; pub use error::{Error, Result}; @@ -30,6 +31,7 @@ pub use host_bindings::{normalize_mac, HostBinding, HostBindings}; pub use log_bus::{LogBus, LogBusLayer, LogLine}; pub use metrics::{HttpRoute, Metrics}; pub use notify::{NotifyConfig, NotifyKind, NotifyStore}; +pub use profile::DeployProfile; pub use queue::{DeploymentQueue, QueueEntry}; pub use saml::{IdpMetadata, SamlError, SpParams, VerifiedPrincipal, VerifiedResponse}; pub use settings::{Settings, SettingsStore, TimeoutAction}; diff --git a/crates/core/src/profile.rs b/crates/core/src/profile.rs new file mode 100644 index 0000000..5e09450 --- /dev/null +++ b/crates/core/src/profile.rs @@ -0,0 +1,122 @@ +//! Per-host deployment profile. +//! +//! v0.5.2: a small, optional bundle of "what should this machine do when +//! it images" attached to either a pinned host binding ([`crate::HostBinding`]) +//! or a queued device ([`crate::QueueEntry`]). All three fields are +//! optional and independent: +//! +//! * `auto_hostname` — substituted into the served unattended answer file +//! (`{{HOSTNAME}}`) so the installer sets the machine name. +//! * `auto_ip` — substituted as `{{IP}}`. OpenPXE is a DHCP **proxy** and +//! does not hand out leases, so this is applied by the installer as a +//! static-network directive inside the answer file, not by DHCP. +//! * `unattended_file` — the id of an uploaded file in the unattended +//! store (Kickstart / Preseed / Autoinstall / Windows answer file). When +//! set, the boot chain injects the appropriate kernel argument so the +//! install runs unattended. + +use serde::{Deserialize, Serialize}; + +/// Optional deployment hints carried on a host pin or a queue entry. +/// +/// The fields are flattened into `HostBinding` / `QueueEntry` on the wire +/// (so existing JSON stays compatible via `#[serde(default)]`); this type +/// is the in-code bundle the boot chain consumes. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeployProfile { + /// Hostname to set on the imaged machine (`{{HOSTNAME}}`). Empty/None + /// leaves the installer default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_hostname: Option, + /// Static IPv4/IPv6 the installer should configure (`{{IP}}`). Stored + /// as a free-form string — validated lightly at the HTTP layer. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_ip: Option, + /// Id of an uploaded file in the unattended store. Empty/None means + /// "no unattended install — boot interactively". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unattended_file: Option, +} + +/// Cap on the stored hostname / IP strings — generous for any real value +/// but bounds what an operator can stuff into the JSON. +pub const MAX_PROFILE_FIELD_LEN: usize = 255; + +impl DeployProfile { + /// True when nothing is set — lets call sites skip work entirely. + #[must_use] + pub fn is_empty(&self) -> bool { + self.auto_hostname.is_none() && self.auto_ip.is_none() && self.unattended_file.is_none() + } + + /// True when an unattended file is selected (drives boot-chain injection). + #[must_use] + pub fn has_unattended(&self) -> bool { + self.unattended_file + .as_deref() + .is_some_and(|s| !s.trim().is_empty()) + } + + /// Normalise: trim every field and collapse empty strings to `None` + /// so persisted JSON never carries `""` for an unset value. + #[must_use] + pub fn normalized(mut self) -> Self { + fn clean(v: Option) -> Option { + v.map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .map(|s| s.chars().take(MAX_PROFILE_FIELD_LEN).collect()) + } + self.auto_hostname = clean(self.auto_hostname); + self.auto_ip = clean(self.auto_ip); + self.unattended_file = clean(self.unattended_file); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_profile_is_empty() { + assert!(DeployProfile::default().is_empty()); + assert!(!DeployProfile::default().has_unattended()); + } + + #[test] + fn normalize_trims_and_nulls_empty() { + let p = DeployProfile { + auto_hostname: Some(" node-7 ".into()), + auto_ip: Some(" ".into()), + unattended_file: Some(String::new()), + } + .normalized(); + assert_eq!(p.auto_hostname.as_deref(), Some("node-7")); + assert_eq!(p.auto_ip, None); + assert_eq!(p.unattended_file, None); + assert!(!p.is_empty()); + } + + #[test] + fn has_unattended_detects_real_id() { + let p = DeployProfile { + unattended_file: Some("ubuntu-ks".into()), + ..Default::default() + }; + assert!(p.has_unattended()); + } + + #[test] + fn long_field_is_capped() { + let long = "a".repeat(1000); + let p = DeployProfile { + auto_hostname: Some(long), + ..Default::default() + } + .normalized(); + assert_eq!( + p.auto_hostname.as_deref().map(str::len), + Some(MAX_PROFILE_FIELD_LEN) + ); + } +} diff --git a/crates/core/src/queue.rs b/crates/core/src/queue.rs index dcf9b29..2b757ec 100644 --- a/crates/core/src/queue.rs +++ b/crates/core/src/queue.rs @@ -21,6 +21,7 @@ use time::OffsetDateTime; use tokio::sync::Notify; use uuid::Uuid; +use crate::profile::DeployProfile; use crate::ClientArch; /// Per-client queue state visible to the WebUI. @@ -37,6 +38,11 @@ pub struct QueueEntry { #[serde(with = "time::serde::rfc3339")] pub last_poll_at: OffsetDateTime, pub assigned_target: Option, + /// v0.5.2: optional per-device deployment profile set via the queue + /// "Profile" button (auto hostname / IP / unattended file). Flattened + /// so the JSON stays flat alongside the other queue fields. + #[serde(default, flatten)] + pub profile: DeployProfile, } #[derive(Debug)] @@ -49,6 +55,7 @@ struct QueueEntryInner { joined_at: OffsetDateTime, last_poll_at: OffsetDateTime, assigned_target: Option, + profile: DeployProfile, /// Broadcast primitive that wakes the long-poll as soon as an /// assignment lands — no polling on our side, no sleep-loops. notify: Arc, @@ -65,6 +72,7 @@ impl QueueEntryInner { joined_at: self.joined_at, last_poll_at: self.last_poll_at, assigned_target: self.assigned_target.clone(), + profile: self.profile.clone(), } } } @@ -110,6 +118,7 @@ impl DeploymentQueue { joined_at: now, last_poll_at: now, assigned_target: None, + profile: DeployProfile::default(), notify: Arc::new(Notify::new()), }; let snap = inner.snapshot(); @@ -133,6 +142,29 @@ impl DeploymentQueue { Some(g.snapshot()) } + /// Operator sets (or clears) the deployment profile for a queued + /// device via the WebUI "Profile" button. Returns the updated + /// snapshot, or `None` if the entry has since been released. + pub fn set_profile(&self, entry_id: &str, profile: DeployProfile) -> Option { + let mut guard = self.inner.write(); + let g = guard.get_mut(entry_id)?; + g.profile = profile.normalized(); + Some(g.snapshot()) + } + + /// Look up the deployment profile for a queued MAC, if any. Used by + /// the boot chain to inject an unattended file / template the + /// hostname + IP when an assigned device chains to its target. + #[must_use] + pub fn profile_for_mac(&self, mac: &str) -> Option { + let guard = self.inner.read(); + guard + .values() + .find(|g| g.mac == mac) + .map(|g| g.profile.clone()) + .filter(|p| !p.is_empty()) + } + /// Operator assigns an ISO entry (boot_entry id) to one or more clients. /// Returns the number of queue entries that were updated. Entries not in the /// queue are silently skipped. diff --git a/crates/http-api/src/app.rs b/crates/http-api/src/app.rs index ab6eddf..81e8b6a 100644 --- a/crates/http-api/src/app.rs +++ b/crates/http-api/src/app.rs @@ -31,11 +31,14 @@ use axum::{ Json, Router, }; use openpxe_core::{ - ext_for_mime, wol, BootEvent, ClientEvent, Error, NotifyConfig, Settings, SsoConfig, - ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES, + ext_for_mime, wol, BootEvent, ClientEvent, DeployProfile, Error, LogoSlot, NotifyConfig, + Settings, SsoConfig, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES, }; use openpxe_ipxe_assets::asset_bytes; -use openpxe_iso_store::{IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest}; +use openpxe_iso_store::{ + render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest, UnattendedKind, + UnattendedMeta, +}; use serde::Deserialize; use serde_json::json; use std::net::SocketAddr; @@ -49,7 +52,14 @@ pub fn build_router(state: AppState) -> Router { .route("/", get(index)) .route("/assets/app.js", get(ui_js)) .route("/assets/app.css", get(ui_css)) + // v0.5.2: theme-aware brand mark. `?theme=light|dark` selects the + // operator's per-theme logo slot (falling back across themes, then + // to the bundled mark). The WebUI swaps `?theme=` on theme toggle. .route("/assets/logo.svg", get(ui_logo)) + // v0.5.2: favicon is pinned to the *bundled* OpenPXE mark for + // continuity — it never follows the operator's custom branding, so + // the browser-tab icon stays recognisably "OpenPXE". + .route("/assets/favicon.svg", get(ui_favicon)) .route("/assets/loader.svg", get(ui_loader)) // v0.4.6: PXE menu logo — the raster form of the operator's // uploaded mark, served so iPXE's `console --picture` can @@ -61,6 +71,16 @@ pub fn build_router(state: AppState) -> Router { // iPXE script endpoints. .route("/boot.ipxe", get(boot_top_menu)) .route("/boot/:filename", get(boot_sub)) + // v0.5.2: unattended answer-file *serving* — public (like /iso), + // because the booting installer fetches these with no session. + // `/unattended/:id` serves a Kickstart/Preseed with `{{HOSTNAME}}` + // / `{{IP}}` / `{{MAC}}` substituted from the query string. The + // 3-segment form is the cloud-init NoCloud seed dir for Ubuntu + // autoinstall (`…//user-data` + `/meta-data`), where `` + // base64url-encodes the per-host hostname/ip/mac. Management + // (upload/list/delete) lives under the gated `/api/unattended`. + .route("/unattended/:id", get(serve_unattended)) + .route("/unattended/:id/:ctx/:sub", get(serve_unattended_seed)) // Bundled binaries and raw ISO access. .route("/ipxe/:name", get(ipxe_binary)) .route("/iso/:filename", get(iso_raw)) @@ -95,12 +115,21 @@ pub fn build_router(state: AppState) -> Router { // volume — surfaced as a small card on the Storage tab so the // operator knows when they're about to run out of room. .route("/api/storage/disk", get(api_storage_disk)) - // v0.4.4: operator-controlled WebUI branding overrides - // (custom logo). Multipart upload to POST; DELETE clears. + // v0.4.4: operator-controlled WebUI branding overrides (custom + // logo). v0.5.2: split into three slots — `light` / `dark` / + // `client`. Multipart upload to POST; DELETE clears one slot. .route( - "/api/branding/logo", + "/api/branding/logo/:slot", post(api_branding_upload).delete(api_branding_clear), ) + // v0.5.2: unattended-install answer-file management (gated). + // Multipart upload, list, delete. Serving is the public + // `/unattended/*` routes above. + .route( + "/api/unattended", + get(api_unattended_list).post(api_unattended_upload), + ) + .route("/api/unattended/:id", delete(api_unattended_delete)) // v0.4.4: self-rendered API reference, served as JSON so the UI // can format it consistently with the rest of the chrome. Lives // under the Settings tab — operators chasing an integration get @@ -133,6 +162,9 @@ pub fn build_router(state: AppState) -> Router { .route("/api/queue/join", get(api_queue_join)) .route("/api/queue/poll/:entry_id", get(api_queue_poll)) .route("/api/queue/assign", post(api_queue_assign)) + // v0.5.2: per-device deployment profile (auto hostname / IP / + // unattended file) set from the queue "Profile" button. + .route("/api/queue/:entry_id/profile", put(api_queue_set_profile)) .route("/api/queue/:entry_id", delete(api_queue_release)) // v0.4.65: SMB share manager (userspace via smbclient). The // kernel-mount NFS routes that v0.4.64 shipped are gone — they @@ -231,7 +263,7 @@ async fn index(State(state): State) -> Response { &state.public_base_url, env!("CARGO_PKG_VERSION"), state.branding.logo_rev(), - state.branding.has_logo(), + state.branding.has_any_web_logo(), ); ( [ @@ -284,23 +316,28 @@ async fn ui_css() -> Response { .into_response() } -async fn ui_logo(State(state): State) -> Response { +#[derive(Debug, Deserialize)] +struct LogoQuery { + /// `light` or `dark` — which theme variant the page is currently + /// showing. Anything else (or absent) resolves to the dark slot, + /// which matches the default theme. + #[serde(default)] + theme: Option, +} + +async fn ui_logo(State(state): State, Query(q): Query) -> Response { // Custom override first; fall back to the bundled rainbow-horizon // SVG. We resolve the override on each request rather than caching // because operators may upload/clear from the Settings tab while the // server is live, and we want them to see their change immediately - // without bouncing the binary. - if let Some(path) = state.branding.logo_path() { - let mime = state - .branding - .logo_mime() - .unwrap_or_else(|| "image/svg+xml".to_string()); + // without bouncing the binary. The theme query selects the per-theme + // slot, with cross-theme + bundled fallback handled in BrandingStore. + let theme_is_light = q.theme.as_deref() == Some("light"); + if let Some((path, mime)) = state.branding.web_logo(theme_is_light) { match tokio::fs::read(&path).await { Ok(bytes) => { - let ct = match HeaderValue::from_str(&mime) { - Ok(v) => v, - Err(_) => HeaderValue::from_static("application/octet-stream"), - }; + let ct = HeaderValue::from_str(&mime) + .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")); return ( [ (header::CONTENT_TYPE, ct), @@ -321,6 +358,17 @@ async fn ui_logo(State(state): State) -> Response { } } } + bundled_logo_response() +} + +/// Favicon — always the bundled OpenPXE mark, decoupled from operator +/// branding (v0.5.2) so the browser-tab icon stays "OpenPXE" for +/// continuity regardless of any uploaded light/dark logo. +async fn ui_favicon() -> Response { + bundled_logo_response() +} + +fn bundled_logo_response() -> Response { ( [ ( @@ -351,8 +399,8 @@ async fn ui_pxe_logo(State(state): State) -> Response { // Resolve the operator's raster upload, if any and if it's a format // iPXE/our compositor can consume. SVG (or a missing/unreadable // file) yields `None`, which composes the default background. - let raster: Option> = match (state.branding.logo_path(), state.branding.logo_mime()) { - (Some(path), Some(mime)) if mime != "image/svg+xml" => tokio::fs::read(&path).await.ok(), + let raster: Option> = match state.branding.client_logo() { + Some((path, mime)) if mime != "image/svg+xml" => tokio::fs::read(&path).await.ok(), _ => None, }; @@ -642,7 +690,26 @@ async fn boot_sub( "PXE boot started", &format!("{who} started booting {} ({}).", iso.filename, entry.title), ); - return text_plain(render_entry(entry, &settings, base)); + // v0.5.2: if this MAC has a deployment profile with + // an unattended answer file selected (via a host + // pin or queue Profile), inject the right kernel + // arg so the install runs unattended. + let unattended_args = mac_normalized.as_deref().and_then(|m| { + resolve_profile(&state, m).and_then(|p| { + p.unattended_file + .as_deref() + .and_then(|fid| state.unattended.get(fid)) + .and_then(|meta| { + build_unattended_args(base, &meta, Some(m), &p) + }) + }) + }); + return text_plain(render_entry( + entry, + &settings, + base, + unattended_args.as_deref(), + )); } } } @@ -1052,7 +1119,18 @@ async fn api_storage_disk(State(state): State) -> Json, mut multipart: Multipart) -> Response { +async fn api_branding_upload( + State(state): State, + AxumPath(slot): AxumPath, + mut multipart: Multipart, +) -> Response { + let Some(slot) = LogoSlot::parse(&slot) else { + return ( + StatusCode::BAD_REQUEST, + "unknown logo slot; expected light, dark, or client", + ) + .into_response(); + }; while let Ok(Some(field)) = multipart.next_field().await { let name = field.name().unwrap_or("").to_string(); if name != "file" && name != "logo" { @@ -1089,11 +1167,21 @@ async fn api_branding_upload(State(state): State, mut multipart: Multi let Some(ext) = ext_for_mime(&mime) else { return (StatusCode::BAD_REQUEST, "unsupported MIME").into_response(); }; - match state.branding.set_logo(&mime, ext, &bytes) { + // The PXE "client" logo is rasterized for the boot screen; an SVG + // there can't be composited, so steer operators to a raster. + if slot == LogoSlot::Client && mime == "image/svg+xml" { + return ( + StatusCode::BAD_REQUEST, + "the client (PXE) logo must be a raster image (PNG/JPEG/WebP/GIF); SVG can't be painted on the boot screen", + ) + .into_response(); + } + match state.branding.set_logo(slot, &mime, ext, &bytes) { Ok(filename) => { return ( StatusCode::OK, Json(json!({ + "slot": slot.as_str(), "filename": filename, "mime": mime, "size_bytes": bytes.len(), @@ -1107,13 +1195,238 @@ async fn api_branding_upload(State(state): State, mut multipart: Multi (StatusCode::BAD_REQUEST, "no 'file' part").into_response() } -async fn api_branding_clear(State(state): State) -> Response { - match state.branding.clear_logo() { +async fn api_branding_clear( + State(state): State, + AxumPath(slot): AxumPath, +) -> Response { + let Some(slot) = LogoSlot::parse(&slot) else { + return (StatusCode::BAD_REQUEST, "unknown logo slot").into_response(); + }; + match state.branding.clear_logo(slot) { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), } } +// ─── Unattended answer files (v0.5.2) ────────────────────────────────────── +// +// Management (list/upload/delete) is gated behind the auth middleware. +// *Serving* the files to the booting installer is the public +// `/unattended/*` route pair below — the installer has no session. + +async fn api_unattended_list(State(state): State) -> Json { + Json(json!({ "files": state.unattended.list() })) +} + +async fn api_unattended_upload( + State(state): State, + mut multipart: Multipart, +) -> Response { + while let Ok(Some(field)) = multipart.next_field().await { + let name = field.name().unwrap_or("").to_string(); + if name != "file" && name != "unattended" { + continue; + } + let filename = field.file_name().map(str::to_string).unwrap_or_default(); + if filename.trim().is_empty() { + return (StatusCode::BAD_REQUEST, "missing filename on upload").into_response(); + } + let bytes = match field.bytes().await { + Ok(b) => b, + Err(e) => return (StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response(), + }; + return match state.unattended.add(&filename, &bytes).await { + Ok(meta) => (StatusCode::CREATED, Json(meta)).into_response(), + Err(Error::Invalid(msg)) => (StatusCode::BAD_REQUEST, msg).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), + }; + } + (StatusCode::BAD_REQUEST, "no 'file' part").into_response() +} + +async fn api_unattended_delete( + State(state): State, + AxumPath(id): AxumPath, +) -> StatusCode { + if state.unattended.remove(&id).await { + StatusCode::NO_CONTENT + } else { + StatusCode::NOT_FOUND + } +} + +#[derive(Debug, Deserialize)] +struct UnattendedServeQuery { + #[serde(default)] + mac: Option, + #[serde(default)] + hostname: Option, + #[serde(default)] + ip: Option, +} + +/// Public: serve a Kickstart/Preseed/answer file with `{{HOSTNAME}}` / +/// `{{IP}}` / `{{MAC}}` substituted from the query string. Returns +/// `text/plain` so installers (anaconda, debian-installer, Windows setup +/// fetching over HTTP) read it verbatim. +async fn serve_unattended( + State(state): State, + AxumPath(id): AxumPath, + Query(q): Query, +) -> Response { + let Ok(bytes) = state.unattended.read(&id).await else { + return (StatusCode::NOT_FOUND, "no such unattended file").into_response(); + }; + let content = String::from_utf8_lossy(&bytes); + let rendered = render_template( + &content, + q.mac.as_deref(), + q.hostname.as_deref(), + q.ip.as_deref(), + ); + text_plain(rendered) +} + +/// Public: cloud-init NoCloud seed directory for Ubuntu autoinstall. The +/// kernel arg points iPXE/cloud-init at `…///`; cloud-init then +/// fetches `user-data`, `meta-data`, (and `vendor-data`). `` +/// base64url-encodes the per-host hostname/ip/mac so they survive the +/// seedfrom URL (which can't carry a query string). +async fn serve_unattended_seed( + State(state): State, + AxumPath((id, ctx, sub)): AxumPath<(String, String, String)>, +) -> Response { + let (mac, hostname, ip) = decode_seed_ctx(&ctx); + match sub.as_str() { + "user-data" => { + let Ok(bytes) = state.unattended.read(&id).await else { + return (StatusCode::NOT_FOUND, "no such unattended file").into_response(); + }; + let content = String::from_utf8_lossy(&bytes); + let rendered = + render_template(&content, mac.as_deref(), hostname.as_deref(), ip.as_deref()); + text_plain(rendered) + } + "meta-data" => { + let host_line = hostname + .as_deref() + .map(|h| format!("local-hostname: {h}\n")) + .unwrap_or_default(); + text_plain(format!("instance-id: openpxe-{id}\n{host_line}")) + } + // cloud-init probes vendor-data too; an empty 200 keeps it quiet. + "vendor-data" => text_plain(String::new()), + _ => (StatusCode::NOT_FOUND, "unknown seed resource").into_response(), + } +} + +/// Resolve the deployment profile for a booting MAC: a host pin wins, else +/// a queued device's Profile. `None` when neither carries one. +fn resolve_profile(state: &AppState, mac: &str) -> Option { + if let Some(b) = state.hosts.lookup(mac) { + if !b.profile.is_empty() { + return Some(b.profile); + } + } + state.queue.profile_for_mac(mac) +} + +/// Build the per-host unattended kernel arguments for a Linux entry. +/// Returns `None` for Windows answer files / unclassified uploads (no +/// kernel cmdline injection applies). +fn build_unattended_args( + base: &str, + meta: &UnattendedMeta, + mac: Option<&str>, + profile: &DeployProfile, +) -> Option { + let base = base.trim_end_matches('/'); + let id = &meta.id; + let host = profile.auto_hostname.as_deref(); + let ip = profile.auto_ip.as_deref(); + let query = build_query(&[("mac", mac), ("hostname", host), ("ip", ip)]); + match meta.kind { + UnattendedKind::Kickstart => Some(format!("inst.ks={base}/unattended/{id}{query}")), + UnattendedKind::Preseed => { + let mut s = format!("auto=true priority=critical url={base}/unattended/{id}{query}"); + if let Some(h) = host { + s.push_str(" hostname="); + s.push_str(h); + } + Some(s) + } + UnattendedKind::Autoinstall => { + let ctx = encode_seed_ctx(mac, host, ip); + Some(format!( + "autoinstall ds=nocloud-net;s={base}/unattended/{id}/{ctx}/" + )) + } + UnattendedKind::AnswerFile | UnattendedKind::Unknown => None, + } +} + +/// Build a `?k=v&…` query string from present key/value pairs, percent- +/// encoding the values. Empty when nothing is present. +fn build_query(pairs: &[(&str, Option<&str>)]) -> String { + use std::fmt::Write as _; + let mut out = String::new(); + for (k, v) in pairs { + if let Some(val) = v { + out.push(if out.is_empty() { '?' } else { '&' }); + let _ = write!(out, "{k}={}", pct_encode(val)); + } + } + out +} + +/// Minimal RFC 3986 percent-encoding for query values (unreserved set +/// passes through; everything else becomes `%XX`). +fn pct_encode(s: &str) -> String { + use std::fmt::Write as _; + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char); + } + _ => { + let _ = write!(out, "%{b:02X}"); + } + } + } + out +} + +/// Encode `(hostname, ip, mac)` into a single base64url path segment for +/// the cloud-init seed directory. Empty values become empty fields. +fn encode_seed_ctx(mac: Option<&str>, hostname: Option<&str>, ip: Option<&str>) -> String { + use base64::Engine as _; + let raw = format!( + "{}\n{}\n{}", + hostname.unwrap_or(""), + ip.unwrap_or(""), + mac.unwrap_or("") + ); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes()) +} + +/// Inverse of [`encode_seed_ctx`]; returns `(mac, hostname, ip)`. A bad +/// or empty segment yields all-`None` so the seed still serves (just +/// without per-host substitution). +fn decode_seed_ctx(ctx: &str) -> (Option, Option, Option) { + use base64::Engine as _; + let Ok(bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(ctx.as_bytes()) else { + return (None, None, None); + }; + let s = String::from_utf8_lossy(&bytes).into_owned(); + let mut it = s.splitn(3, '\n'); + let clean = |v: Option<&str>| v.map(str::to_string).filter(|x| !x.is_empty()); + let hostname = clean(it.next()); + let ip = clean(it.next()); + let mac = clean(it.next()); + (mac, hostname, ip) +} + // ─── API reference (Settings → bottom) ──────────────────────────────────── async fn api_docs() -> Json { @@ -1209,12 +1522,12 @@ async fn api_docs() -> Json { "summary": "Current runtime settings (Windows toggle, timeout, dns hint, …)."}, {"method": "PUT", "path": "/api/settings", "summary": "Replace runtime settings. Guards against enabling Windows when wimboot isn't bundled."}, - {"method": "POST", "path": "/api/branding/logo", - "summary": "Upload a custom WebUI logo (multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB)."}, - {"method": "DELETE", "path": "/api/branding/logo", - "summary": "Remove the custom logo and revert to the bundled mark."}, + {"method": "POST", "path": "/api/branding/logo/:slot", + "summary": "Upload a custom logo for a slot (light | dark | client). Multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB. The client slot is raster-only."}, + {"method": "DELETE", "path": "/api/branding/logo/:slot", + "summary": "Remove the custom logo for a slot and revert to the bundled mark."}, {"method": "GET", "path": "/branding/pxe-logo", - "summary": "Raster form of the operator's logo for the iPXE menu's `console --picture`. SVG uploads 404 here."}, + "summary": "Raster form of the operator's 'client' logo for the iPXE menu's `console --picture`. Default background when unset/SVG."}, {"method": "GET", "path": "/api/sso", "summary": "Current SAML SSO configuration."}, {"method": "PUT", "path": "/api/sso", @@ -1245,13 +1558,28 @@ async fn api_docs() -> Json { "summary": "Free / used / total bytes for the volume hosting the ISO directory."}, ], }, + { + "name": "Unattended answer files", + "endpoints": [ + {"method": "GET", "path": "/api/unattended", + "summary": "List uploaded answer files (Kickstart / Preseed / Autoinstall / Windows answer file)."}, + {"method": "POST", "path": "/api/unattended", + "summary": "Upload an answer file (multipart 'file', .ks/.cfg/.seed/.yaml/.yml/.xml/user-data, up to 1 MB)."}, + {"method": "DELETE", "path": "/api/unattended/:id", + "summary": "Delete an uploaded answer file."}, + {"method": "GET", "path": "/unattended/:id", + "summary": "Public: serve an answer file with {{HOSTNAME}}/{{IP}}/{{MAC}} substituted from the query string."}, + ], + }, { "name": "Queued Deployment", "endpoints": [ {"method": "GET", "path": "/api/queue", - "summary": "List queue entries (waiting + assigned)."}, + "summary": "List queue entries (waiting + assigned, with any deployment profile)."}, {"method": "POST", "path": "/api/queue/assign", "summary": "Assign a target image to queued clients. Body: { target, entry_ids }."}, + {"method": "PUT", "path": "/api/queue/:entry_id/profile", + "summary": "Set a queued device's deployment profile. Body: { auto_hostname?, auto_ip?, unattended_file? }."}, {"method": "DELETE", "path": "/api/queue/:entry_id", "summary": "Release a queue entry without assigning."}, ], @@ -1262,7 +1590,7 @@ async fn api_docs() -> Json { {"method": "GET", "path": "/api/hosts", "summary": "List per-MAC boot bindings."}, {"method": "POST", "path": "/api/hosts", - "summary": "Pin a MAC to a boot target. Body: { mac, target, label }."}, + "summary": "Pin a MAC to a boot target. Body: { mac, target, label, auto_hostname?, auto_ip?, unattended_file? }."}, {"method": "DELETE", "path": "/api/hosts/:mac", "summary": "Remove a binding."}, {"method": "POST", "path": "/api/hosts/:mac/wol", @@ -1672,7 +2000,14 @@ async fn api_status(State(state): State) -> Json { "nfs_share_count": nfs_shares.len(), "nfs_share_reachable": nfs_reachable, "host_bindings": state.hosts.len(), - "custom_logo": state.branding.has_logo(), + "custom_logo": state.branding.has_any_web_logo(), + "branding": { + "light": state.branding.has_logo(LogoSlot::Light), + "dark": state.branding.has_logo(LogoSlot::Dark), + "client": state.branding.has_logo(LogoSlot::Client), + "rev": state.branding.logo_rev(), + }, + "unattended_count": state.unattended.len(), "uptime_secs": uptime_secs, "started_at": state.started_at, "nic_name": state.nic_name, @@ -1819,10 +2154,15 @@ async fn api_queue_poll( entry_id=%entry_id, mac=%g.mac, target=%target, "queue assignment delivered" ); + // Carry `?mac=` so the per-entry handler can resolve this + // device's deployment profile (auto hostname/IP + unattended + // file) and inject the unattended kernel args, mirroring the + // pinned-host path. + let qmac = g.mac.clone(); text_plain(format!( "#!ipxe\n\ echo Queue assignment received: {target}\n\ - chain {base}/boot/{target}.ipxe || chain {base}/api/queue/poll/{entry_id}\n" + chain {base}/boot/{target}.ipxe?mac={qmac} || chain {base}/api/queue/poll/{entry_id}\n" )) } Some(g) => { @@ -1875,6 +2215,21 @@ async fn api_queue_assign( Json(json!({ "ok": true, "assigned": n, "target": body.target })) } +async fn api_queue_set_profile( + State(state): State, + AxumPath(entry_id): AxumPath, + Json(body): Json, +) -> Response { + let profile = body.normalized(); + if let Err(msg) = validate_profile(&state, &profile) { + return (StatusCode::BAD_REQUEST, msg).into_response(); + } + match state.queue.set_profile(&entry_id, profile) { + Some(entry) => (StatusCode::OK, Json(entry)).into_response(), + None => (StatusCode::NOT_FOUND, "no such queue entry").into_response(), + } +} + async fn api_queue_release( State(state): State, AxumPath(entry_id): AxumPath, @@ -2013,6 +2368,11 @@ struct HostsUpsertBody { target: String, #[serde(default)] label: String, + /// v0.5.2: optional unattended-install profile. Flattened so the + /// front-end posts `auto_hostname` / `auto_ip` / `unattended_file` + /// at the top level alongside mac/target/label. + #[serde(default, flatten)] + profile: DeployProfile, } async fn api_hosts_upsert( @@ -2040,10 +2400,32 @@ async fn api_hosts_upsert( ) .into_response(); } - let binding = state.hosts.upsert(mac, target, body.label.trim()); + let profile = body.profile.normalized(); + if let Err(msg) = validate_profile(&state, &profile) { + return (StatusCode::BAD_REQUEST, msg).into_response(); + } + let binding = state.hosts.upsert(mac, target, body.label.trim(), profile); (StatusCode::CREATED, Json(binding)).into_response() } +/// Shared validation for a deployment profile (host pin + queue profile): +/// the referenced unattended file must exist, and a supplied IP must +/// parse. Hostname is free-form (installers vary), so we only length-cap +/// it (done in `DeployProfile::normalized`). +fn validate_profile(state: &AppState, profile: &DeployProfile) -> Result<(), String> { + if let Some(id) = profile.unattended_file.as_deref() { + if state.unattended.get(id).is_none() { + return Err(format!("unknown unattended file: {id}")); + } + } + if let Some(ip) = profile.auto_ip.as_deref() { + if ip.parse::().is_err() { + return Err(format!("auto_ip is not a valid IP address: {ip}")); + } + } + Ok(()) +} + async fn api_hosts_remove( State(state): State, AxumPath(mac): AxumPath, @@ -2326,6 +2708,107 @@ async fn api_metrics(State(state): State) -> Response { mod tests { use super::*; + fn meta(kind: UnattendedKind) -> UnattendedMeta { + UnattendedMeta { + id: "ks1".into(), + filename: "f".into(), + kind, + size_bytes: 0, + uploaded_at: time::OffsetDateTime::UNIX_EPOCH, + } + } + + #[test] + fn unattended_kickstart_arg_carries_query() { + let p = DeployProfile { + auto_hostname: Some("node7".into()), + auto_ip: Some("10.0.0.7".into()), + unattended_file: Some("ks1".into()), + }; + let a = build_unattended_args( + "http://h", + &meta(UnattendedKind::Kickstart), + Some("aa:bb:cc:dd:ee:ff"), + &p, + ) + .unwrap(); + assert!(a.starts_with("inst.ks=http://h/unattended/ks1?"), "{a}"); + assert!(a.contains("hostname=node7"), "{a}"); + assert!(a.contains("ip=10.0.0.7"), "{a}"); + // MAC colons are percent-encoded. + assert!(a.contains("mac=aa%3Abb%3Acc%3Add%3Aee%3Aff"), "{a}"); + } + + #[test] + fn unattended_preseed_appends_hostname_kernel_arg() { + let p = DeployProfile { + auto_hostname: Some("deb1".into()), + ..Default::default() + }; + let a = + build_unattended_args("http://h/", &meta(UnattendedKind::Preseed), None, &p).unwrap(); + assert!( + a.starts_with("auto=true priority=critical url=http://h/unattended/ks1"), + "{a}" + ); + assert!(a.ends_with(" hostname=deb1"), "{a}"); + } + + #[test] + fn unattended_autoinstall_uses_nocloud_seed_dir() { + let p = DeployProfile { + auto_hostname: Some("u1".into()), + auto_ip: Some("10.1.1.5".into()), + unattended_file: Some("ks1".into()), + }; + let a = build_unattended_args( + "http://h", + &meta(UnattendedKind::Autoinstall), + Some("aa:bb"), + &p, + ) + .unwrap(); + assert!( + a.starts_with("autoinstall ds=nocloud-net;s=http://h/unattended/ks1/"), + "{a}" + ); + assert!(a.ends_with('/'), "seed URL must end with '/': {a}"); + // The ctx segment round-trips back to the per-host values. + let ctx = a.trim_end_matches('/').rsplit('/').next().unwrap(); + let (mac, host, ip) = decode_seed_ctx(ctx); + assert_eq!(mac.as_deref(), Some("aa:bb")); + assert_eq!(host.as_deref(), Some("u1")); + assert_eq!(ip.as_deref(), Some("10.1.1.5")); + } + + #[test] + fn windows_answer_file_is_not_injected() { + let p = DeployProfile { + unattended_file: Some("ks1".into()), + ..Default::default() + }; + assert!( + build_unattended_args("http://h", &meta(UnattendedKind::AnswerFile), None, &p) + .is_none() + ); + } + + #[test] + fn seed_ctx_empty_segment_decodes_to_none() { + let ctx = encode_seed_ctx(None, None, None); + let (m, h, i) = decode_seed_ctx(&ctx); + assert!(m.is_none() && h.is_none() && i.is_none()); + // Garbage decodes safely to all-None. + let (m2, h2, i2) = decode_seed_ctx("!!!not-base64!!!"); + assert!(m2.is_none() && h2.is_none() && i2.is_none()); + } + + #[test] + fn pct_encode_escapes_reserved() { + assert_eq!(pct_encode("aa:bb cc"), "aa%3Abb%20cc"); + assert_eq!(pct_encode("node-7.lab_1~"), "node-7.lab_1~"); + } + #[test] fn version_newer_detects_updates() { assert!(version_is_newer("0.5.1", "0.5.0")); diff --git a/crates/http-api/src/auth.rs b/crates/http-api/src/auth.rs index 9ee83e7..c5a6f4f 100644 --- a/crates/http-api/src/auth.rs +++ b/crates/http-api/src/auth.rs @@ -317,7 +317,7 @@ pub async fn api_me(State(state): State, headers: axum::http::HeaderMa // screens can render the FleetDM-style full-width custom logo (and // cache-bust it) without an extra round trip. `/api/me` is public, // and the logo asset is public, so this leaks nothing sensitive. - let has_custom_logo = state.branding.has_logo(); + let has_custom_logo = state.branding.has_any_web_logo(); let logo_rev = state.branding.logo_rev(); if !state.admin.is_configured() { return ( diff --git a/crates/http-api/src/ipxe_script.rs b/crates/http-api/src/ipxe_script.rs index b742027..3ef1f19 100644 --- a/crates/http-api/src/ipxe_script.rs +++ b/crates/http-api/src/ipxe_script.rs @@ -460,8 +460,21 @@ pub fn render_queue_entry(base_url: &str) -> String { } /// Per-entry boot script (same as Phase 1, with extra_kernel_args appended). +/// +/// `unattended_args` (v0.5.2) carries the per-host unattended-install +/// kernel arguments (`inst.ks=…`, `auto=true … url=…`, or +/// `autoinstall ds=nocloud-net;s=…`) when the requesting MAC has a +/// deployment profile with an answer file selected. It's appended to the +/// Linux kernel command line after the operator's global extra args, and +/// ignored for Windows (wimboot) / sanboot entries which don't take a +/// kernel cmdline. #[must_use] -pub fn render_entry(entry: &BootEntry, settings: &Settings, base_url: &str) -> String { +pub fn render_entry( + entry: &BootEntry, + settings: &Settings, + base_url: &str, + unattended_args: Option<&str>, +) -> String { let mut s = String::new(); let base = base_url.trim_end_matches('/'); let _ = writeln!(s, "#!ipxe"); @@ -477,6 +490,12 @@ pub fn render_entry(entry: &BootEntry, settings: &Settings, base_url: &str) -> S cmdline.push(' '); cmdline.push_str(settings.extra_kernel_args.trim()); } + if let Some(extra) = unattended_args { + if !extra.trim().is_empty() { + cmdline.push(' '); + cmdline.push_str(extra.trim()); + } + } let _ = writeln!(s, "kernel {base}/{kernel_url} {cmdline}"); for u in initrd_urls { let _ = writeln!(s, "initrd {base}/{u}"); diff --git a/crates/http-api/src/state.rs b/crates/http-api/src/state.rs index 0713faf..69d092f 100644 --- a/crates/http-api/src/state.rs +++ b/crates/http-api/src/state.rs @@ -5,7 +5,7 @@ use openpxe_core::{ AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, NotifyStore, SettingsStore, SsoStore, }; -use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager}; +use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager, UnattendedStore}; use std::sync::Arc; use time::OffsetDateTime; @@ -67,6 +67,11 @@ pub struct AppState { /// In-process (no subprocess); supports HTTP Range requests on /// NFS-sourced ISOs because NFSv3 READ3 takes an explicit offset. pub nfs_shares: NfsShareManager, + /// v0.5.2: uploaded unattended-install answer files (Kickstart / + /// Preseed / Autoinstall / Windows answer files). Served on demand to + /// booting clients with per-host hostname/IP/MAC templating; lives in + /// its own directory, never the ISO listing or PXE menu. + pub unattended: UnattendedStore, /// Browser chunked upload state. Multipart uploads still go straight /// through `IsoStore`, but the UI uses sessions so large ISO transfers /// can show deterministic progress and leave visible partial files. diff --git a/crates/http-api/tests/full_flow.rs b/crates/http-api/tests/full_flow.rs index 05e84a2..312f9b9 100644 --- a/crates/http-api/tests/full_flow.rs +++ b/crates/http-api/tests/full_flow.rs @@ -96,6 +96,8 @@ async fn build_state() -> (AppState, tempfile::TempDir) { let settings = SettingsStore::load_or_default(dir.path()); let smb_shares = SmbShareManager::new(dir.path(), iso_store.clone()); let nfs_shares = NfsShareManager::new(dir.path(), iso_store.clone()); + let unattended = openpxe_iso_store::UnattendedStore::new(dir.path().join("unattended")); + unattended.ensure_dir().await.unwrap(); let log_bus = LogBus::new(64); let hosts = HostBindings::load_or_default(dir.path()); let boot_log = openpxe_core::BootLog::load_or_default(dir.path()); @@ -122,6 +124,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) { smb: None, smb_shares, nfs_shares, + unattended, uploads: openpxe_http_api::uploads::UploadSessions::default(), log_bus, started_at: time::OffsetDateTime::now_utc(), @@ -1488,7 +1491,8 @@ async fn api_docs_lists_known_endpoints() { "/api/isos", "/api/isos/:id/category", "/api/storage/disk", - "/api/branding/logo", + "/api/branding/logo/:slot", + "/api/unattended", "/api/boot-log", "/metrics", ] { @@ -1509,7 +1513,7 @@ async fn branding_clear_when_no_logo_is_no_content() { .oneshot( Request::builder() .method("DELETE") - .uri("/api/branding/logo") + .uri("/api/branding/logo/dark") .body(Body::empty()) .unwrap(), ) @@ -1950,6 +1954,7 @@ async fn pxe_background_falls_back_to_default_for_svg_upload() { state .branding .set_logo( + openpxe_core::LogoSlot::Client, "image/svg+xml", "svg", br#""#, @@ -1985,7 +1990,10 @@ async fn pxe_logo_composes_to_1024x768_png() { // the iPXE menu always paints at consistent dimensions. let (state, _dir) = build_state().await; let png = tiny_png(); - state.branding.set_logo("image/png", "png", &png).unwrap(); + state + .branding + .set_logo(openpxe_core::LogoSlot::Client, "image/png", "png", &png) + .unwrap(); let app = build_router(state); let res = app .clone() @@ -2025,7 +2033,10 @@ async fn pxe_logo_endpoint_is_public_after_admin_setup() { // auth allowlist gates `/api/*` only. let (state, _dir) = build_state().await; let png = tiny_png(); - state.branding.set_logo("image/png", "png", &png).unwrap(); + state + .branding + .set_logo(openpxe_core::LogoSlot::Client, "image/png", "png", &png) + .unwrap(); let app = build_router(state); // Configure an admin so the middleware kicks in. let (s, _, _) = post_collect( @@ -2040,6 +2051,201 @@ async fn pxe_logo_endpoint_is_public_after_admin_setup() { assert_eq!(s, StatusCode::OK); } +// ─── v0.5.2: unattended files + deployment profiles ───────────────────────── + +async fn post_multipart( + router: &axum::Router, + path: &str, + ct: &str, + body: Vec, +) -> (StatusCode, Vec) { + let res = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(path) + .header("content-type", ct) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + let status = res.status(); + let body = axum::body::to_bytes(res.into_body(), usize::MAX) + .await + .unwrap() + .to_vec(); + (status, body) +} + +#[tokio::test] +async fn unattended_upload_list_serve_and_template() { + let (state, _dir) = build_state().await; + let app = build_router(state); + let ks = b"install\nnetwork --hostname={{HOSTNAME}} --ip={{IP}}\n%packages\n@core\n%end\n"; + let (ct, body) = multipart_iso_body("rocky.ks", ks); + let (s, b) = post_multipart(&app, "/api/unattended", &ct, body).await; + assert_eq!(s, StatusCode::CREATED, "{}", String::from_utf8_lossy(&b)); + let m: serde_json::Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(m["kind"], "kickstart"); + let id = m["id"].as_str().unwrap().to_string(); + + let (s, b) = get(&app, "/api/unattended").await; + assert_eq!(s, StatusCode::OK); + let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(v["files"].as_array().unwrap().len(), 1); + + // Public serve substitutes the query tokens. + let (s, b) = get( + &app, + &format!("/unattended/{id}?hostname=node7&ip=10.0.0.7"), + ) + .await; + assert_eq!(s, StatusCode::OK); + let text = String::from_utf8_lossy(&b); + assert!(text.contains("--hostname=node7"), "got: {text}"); + assert!(text.contains("--ip=10.0.0.7"), "got: {text}"); + assert!(!text.contains("{{"), "tokens left unrendered: {text}"); + + // Delete. + let res = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/api/unattended/{id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::NO_CONTENT); + let (_, b) = get(&app, "/api/unattended").await; + let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(v["files"].as_array().unwrap().len(), 0); +} + +#[tokio::test] +async fn unattended_upload_rejects_bad_type() { + let (state, _dir) = build_state().await; + let app = build_router(state); + let (ct, body) = multipart_iso_body("evil.sh", b"#!/bin/sh\n"); + let (s, _) = post_multipart(&app, "/api/unattended", &ct, body).await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn host_pin_with_unattended_injects_kickstart_arg() { + let (state, _dir) = build_state().await; + let app = build_router(state.clone()); + // Upload a Linux ISO → synthesises the `fake-alpine-linux` LinuxKernel entry. + let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso()); + let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await; + assert_eq!(s, StatusCode::CREATED); + // Upload a kickstart. + let (ct, body) = multipart_iso_body("ks.ks", b"install\n%packages\n@core\n%end\n"); + let (s, b) = post_multipart(&app, "/api/unattended", &ct, body).await; + assert_eq!(s, StatusCode::CREATED); + let ks_id = serde_json::from_slice::(&b).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + // Pin a MAC to the Linux entry with the unattended profile. + let mac = "aa:bb:cc:dd:ee:01"; + let pin = format!( + r#"{{"mac":"{mac}","target":"fake-alpine-linux","label":"lab","auto_hostname":"node7","auto_ip":"10.0.0.7","unattended_file":"{ks_id}"}}"# + ); + let (s, b) = post_json(&app, "/api/hosts", &pin).await; + assert_eq!(s, StatusCode::CREATED, "{}", String::from_utf8_lossy(&b)); + // Boot the entry as that MAC; the kernel line should carry inst.ks=. + let (s, b) = get(&app, &format!("/boot/fake-alpine-linux.ipxe?mac={mac}")).await; + assert_eq!(s, StatusCode::OK); + let script = String::from_utf8_lossy(&b); + assert!( + script.contains("inst.ks="), + "no kickstart arg injected:\n{script}" + ); + assert!( + script.contains("hostname=node7"), + "hostname not passed:\n{script}" + ); +} + +#[tokio::test] +async fn host_pin_rejects_unknown_unattended_file() { + let (state, _dir) = build_state().await; + let app = build_router(state.clone()); + let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso()); + let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await; + assert_eq!(s, StatusCode::CREATED); + let pin = r#"{"mac":"aa:bb:cc:dd:ee:02","target":"fake-alpine-linux","unattended_file":"does-not-exist"}"#; + let (s, _) = post_json(&app, "/api/hosts", pin).await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn host_pin_rejects_bad_auto_ip() { + let (state, _dir) = build_state().await; + let app = build_router(state.clone()); + let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso()); + let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await; + assert_eq!(s, StatusCode::CREATED); + let pin = r#"{"mac":"aa:bb:cc:dd:ee:03","target":"fake-alpine-linux","auto_ip":"not-an-ip"}"#; + let (s, _) = post_json(&app, "/api/hosts", pin).await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn per_theme_logo_and_favicon_serve() { + let (state, _dir) = build_state().await; + // Light slot only; dark falls back to it, favicon stays bundled. + let png = tiny_png(); + state + .branding + .set_logo(openpxe_core::LogoSlot::Light, "image/png", "png", &png) + .unwrap(); + let app = build_router(state); + // Light theme → the uploaded PNG. + let (s, b) = get(&app, "/assets/logo.svg?theme=light").await; + assert_eq!(s, StatusCode::OK); + assert!(b.starts_with(b"\x89PNG"), "light slot should serve the PNG"); + // Dark theme → falls back to the light PNG (only slot set). + let (s, b) = get(&app, "/assets/logo.svg?theme=dark").await; + assert_eq!(s, StatusCode::OK); + assert!( + b.starts_with(b"\x89PNG"), + "dark should fall back to light PNG" + ); + // Favicon is always the bundled SVG, never the custom raster. + let (s, b) = get(&app, "/assets/favicon.svg").await; + assert_eq!(s, StatusCode::OK); + let txt = String::from_utf8_lossy(&b); + assert!(txt.contains(""#; + let boundary = "----OpenPxeTestBoundary1234"; + let mut b = Vec::new(); + b.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + b.extend_from_slice(b"Content-Disposition: form-data; name=\"file\"; filename=\"l.svg\"\r\n"); + b.extend_from_slice(b"Content-Type: image/svg+xml\r\n\r\n"); + b.extend_from_slice(svg); + b.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); + let ct = format!("multipart/form-data; boundary={boundary}"); + let (s, _) = post_multipart(&app, "/api/branding/logo/client", &ct, b).await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} + // ─── v0.5.1: SAML SSO flow ────────────────────────────────────────────────── // // The core crate exhaustively tests signature verification + semantic @@ -2145,8 +2351,16 @@ fn urlencode(s: &str) -> String { } _ => { out.push('%'); - out.push(char::from_digit((b >> 4) as u32, 16).unwrap().to_ascii_uppercase()); - out.push(char::from_digit((b & 0xf) as u32, 16).unwrap().to_ascii_uppercase()); + out.push( + char::from_digit((b >> 4) as u32, 16) + .unwrap() + .to_ascii_uppercase(), + ); + out.push( + char::from_digit((b & 0xf) as u32, 16) + .unwrap() + .to_ascii_uppercase(), + ); } } } diff --git a/crates/iso-store/src/lib.rs b/crates/iso-store/src/lib.rs index ed56612..f947a62 100644 --- a/crates/iso-store/src/lib.rs +++ b/crates/iso-store/src/lib.rs @@ -23,6 +23,7 @@ pub mod pxe_logo; pub mod smb; pub mod smb_share; pub mod store; +pub mod unattended; pub mod windows; pub use entry::{BootEntry, BootKind, KernelArgs}; @@ -40,7 +41,10 @@ pub use smb_share::{SmbAddRequest, SmbShare, SmbShareError, SmbShareManager, Smb // Range requests because NFSv3 READ3 takes an explicit offset. pub use nfs_share::{NfsAddRequest, NfsShare, NfsShareError, NfsShareManager, NfsStream}; pub use store::{ - generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore, - UploadHandle, + generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore, UploadHandle, +}; +pub use unattended::{ + classify as classify_unattended, render_template, UnattendedKind, UnattendedMeta, + UnattendedStore, MAX_UNATTENDED_BYTES, }; pub use windows::{WimPatcher, WinPatchState}; diff --git a/crates/iso-store/src/unattended.rs b/crates/iso-store/src/unattended.rs new file mode 100644 index 0000000..eecee5e --- /dev/null +++ b/crates/iso-store/src/unattended.rs @@ -0,0 +1,412 @@ +//! Unattended-install answer-file store (v0.5.2). +//! +//! Operators upload the answer file their installer expects — a RHEL/ +//! Fedora **Kickstart**, a Debian **Preseed**, an Ubuntu **Autoinstall** +//! cloud-init user-data, or a Windows **answer file** (`autounattend.xml`) +//! — and OpenPXE serves it on demand to the booting machine. Files live +//! in their own directory (`/`), deliberately *not* under +//! `iso_dir`, so they never appear in the ISO listing or the PXE menu. +//! +//! Storage mirrors [`crate::store::IsoStore`]: in-memory map authoritative +//! for the process, sidecar `*.meta.json` on disk is the source of truth on +//! restart. The raw answer file sits beside it as `.file`. +//! +//! Templating is applied at *serve* time, not store time — see +//! [`render_template`]. The stored bytes are exactly what the operator +//! uploaded; per-host hostname/IP/MAC values are substituted into a copy +//! when the file is fetched for a specific client. + +use crate::store::slugify_str; +use openpxe_core::{Error, Result}; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use time::OffsetDateTime; + +/// Disk + memory cap for one answer file. Kickstarts/preseeds/cloud-init +/// configs are a few KB; 1 MiB is a comfortable ceiling that still bounds +/// abuse. +pub const MAX_UNATTENDED_BYTES: usize = 1024 * 1024; + +/// Which installer the answer file targets. Drives the kernel-argument +/// injection in the boot chain. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UnattendedKind { + /// RHEL / Fedora / CentOS / AlmaLinux / Rocky — `inst.ks=`. + Kickstart, + /// Debian / older Ubuntu — `auto=true priority=critical url=`. + Preseed, + /// Ubuntu 20.04+ Subiquity autoinstall — cloud-init NoCloud: + /// `autoinstall ds=nocloud-net;s=/`. + Autoinstall, + /// Windows Setup answer file (`autounattend.xml`). Served, not + /// auto-injected (Windows reads it from media/USB, not a kernel arg). + AnswerFile, + /// Couldn't classify — stored + served, no auto-injection. + #[default] + Unknown, +} + +impl UnattendedKind { + #[must_use] + pub fn label(self) -> &'static str { + match self { + UnattendedKind::Kickstart => "Kickstart", + UnattendedKind::Preseed => "Preseed", + UnattendedKind::Autoinstall => "Autoinstall", + UnattendedKind::AnswerFile => "Answer file", + UnattendedKind::Unknown => "Unknown", + } + } +} + +/// Lowercase file extension (no dot), or `None` if there isn't one. +fn ext_lower(filename: &str) -> Option { + std::path::Path::new(filename) + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) +} + +/// Classify an upload from its filename + a peek at its content. Best +/// effort: extension first, then a content sniff to disambiguate the +/// `.cfg` case (both Kickstart and Preseed use it). +#[must_use] +pub fn classify(filename: &str, content: &[u8]) -> UnattendedKind { + let lower_name = filename.to_ascii_lowercase(); + let ext = ext_lower(filename); + let text = String::from_utf8_lossy(&content[..content.len().min(8192)]); + let looks_preseed = text.contains("d-i ") || text.contains("preseed/"); + let looks_kickstart = text.contains("%packages") + || text.contains("\nlang ") + || text.contains("\nkeyboard ") + || text.contains("bootloader --") + || text.starts_with("install"); + let looks_cloud_init = text.contains("autoinstall") + || text.contains("#cloud-config") + || text.contains("version: 1"); + + match ext.as_deref() { + Some("ks") => return UnattendedKind::Kickstart, + Some("seed") => return UnattendedKind::Preseed, + Some("xml") => return UnattendedKind::AnswerFile, + Some("yaml" | "yml") => return UnattendedKind::Autoinstall, + Some("cfg") => { + return if looks_kickstart && !looks_preseed { + UnattendedKind::Kickstart + } else { + UnattendedKind::Preseed + }; + } + _ => {} + } + if lower_name == "user-data" { + return UnattendedKind::Autoinstall; + } + // No recognised extension — fall back to content sniffing. + if looks_cloud_init { + UnattendedKind::Autoinstall + } else if looks_kickstart { + UnattendedKind::Kickstart + } else if looks_preseed { + UnattendedKind::Preseed + } else { + UnattendedKind::Unknown + } +} + +/// True if the filename carries an extension we accept for upload. We +/// also accept the bare `user-data` name (cloud-init NoCloud convention). +#[must_use] +pub fn is_accepted_filename(filename: &str) -> bool { + if filename.trim().eq_ignore_ascii_case("user-data") { + return true; + } + matches!( + ext_lower(filename).as_deref(), + Some("ks" | "cfg" | "seed" | "yaml" | "yml" | "xml") + ) +} + +/// Sidecar metadata for a stored answer file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnattendedMeta { + /// URL-safe slug, unique within the store. + pub id: String, + /// Original upload filename, shown in the UI. + pub filename: String, + pub kind: UnattendedKind, + pub size_bytes: u64, + #[serde(with = "time::serde::rfc3339")] + pub uploaded_at: OffsetDateTime, +} + +#[derive(Debug, Default)] +struct Inner { + files: HashMap, +} + +/// In-memory + on-disk answer-file registry. Cheap to clone. +#[derive(Debug, Clone)] +pub struct UnattendedStore { + dir: Arc, + inner: Arc>, +} + +impl UnattendedStore { + #[must_use] + pub fn new(dir: PathBuf) -> Self { + Self { + dir: Arc::new(dir), + inner: Arc::new(RwLock::new(Inner::default())), + } + } + + pub async fn ensure_dir(&self) -> Result<()> { + tokio::fs::create_dir_all(self.dir.as_path()).await?; + Ok(()) + } + + /// Scan the directory on startup, loading every `*.meta.json` sidecar. + pub async fn load_from_disk(&self) -> Result<()> { + self.ensure_dir().await?; + let mut entries = tokio::fs::read_dir(self.dir.as_path()).await?; + while let Some(e) = entries.next_entry().await? { + let p = e.path(); + let is_meta = p + .file_name() + .and_then(|s| s.to_str()) + .is_some_and(|n| n.ends_with(".meta.json")); + if !is_meta { + continue; + } + if let Ok(text) = tokio::fs::read_to_string(&p).await { + if let Ok(meta) = serde_json::from_str::(&text) { + self.inner.write().files.insert(meta.id.clone(), meta); + } + } + } + Ok(()) + } + + fn data_path(&self, id: &str) -> PathBuf { + self.dir.join(format!("{id}.file")) + } + + fn meta_path(&self, id: &str) -> PathBuf { + self.dir.join(format!("{id}.meta.json")) + } + + /// Mint a unique slug from the upload filename's stem. + fn unique_id(&self, filename: &str) -> String { + let stem = filename.rsplit_once('.').map_or(filename, |(s, _)| s); + let base = { + let s = slugify_str(stem); + if s.is_empty() { + "unattended".to_string() + } else { + s + } + }; + let g = self.inner.read(); + if !g.files.contains_key(&base) { + return base; + } + for n in 1.. { + let candidate = format!("{base}-{n}"); + if !g.files.contains_key(&candidate) { + return candidate; + } + } + unreachable!("u64 ids exhausted") + } + + /// Store an uploaded answer file. Validates type + size, classifies, + /// writes the bytes + a sidecar, and returns the new metadata. + pub async fn add(&self, filename: &str, bytes: &[u8]) -> Result { + if !is_accepted_filename(filename) { + return Err(Error::Invalid(format!( + "unsupported answer-file type '{filename}'. Accepted: .ks, .cfg, .seed, .yaml, .yml, .xml, user-data" + ))); + } + if bytes.len() > MAX_UNATTENDED_BYTES { + return Err(Error::Invalid(format!( + "answer file too large ({} bytes, max {MAX_UNATTENDED_BYTES})", + bytes.len() + ))); + } + self.ensure_dir().await?; + let kind = classify(filename, bytes); + let id = self.unique_id(filename); + let meta = UnattendedMeta { + id: id.clone(), + filename: filename.to_string(), + kind, + size_bytes: bytes.len() as u64, + uploaded_at: OffsetDateTime::now_utc(), + }; + // Atomic data write: tmp -> rename. + let data = self.data_path(&id); + let tmp = data.with_extension("file.tmp"); + tokio::fs::write(&tmp, bytes).await?; + tokio::fs::rename(&tmp, &data).await?; + let meta_text = serde_json::to_string_pretty(&meta).map_err(|e| Error::Other(e.into()))?; + tokio::fs::write(self.meta_path(&id), meta_text).await?; + self.inner.write().files.insert(id.clone(), meta.clone()); + tracing::info!( + target: "openpxe::unattended", + id = %id, file = %filename, kind = ?kind, size = bytes.len(), + "unattended answer file stored" + ); + Ok(meta) + } + + #[must_use] + pub fn list(&self) -> Vec { + let g = self.inner.read(); + let mut v: Vec<_> = g.files.values().cloned().collect(); + v.sort_by_key(|m| std::cmp::Reverse(m.uploaded_at)); + v + } + + #[must_use] + pub fn get(&self, id: &str) -> Option { + self.inner.read().files.get(id).cloned() + } + + /// Read the raw stored bytes for `id`. + pub async fn read(&self, id: &str) -> Result> { + if !self.inner.read().files.contains_key(id) { + return Err(Error::NotFound(format!("no unattended file '{id}'"))); + } + let bytes = tokio::fs::read(self.data_path(id)).await?; + Ok(bytes) + } + + /// Remove a file + its sidecar. Returns true if something was removed. + pub async fn remove(&self, id: &str) -> bool { + let existed = self.inner.write().files.remove(id).is_some(); + if existed { + let _ = tokio::fs::remove_file(self.data_path(id)).await; + let _ = tokio::fs::remove_file(self.meta_path(id)).await; + } + existed + } + + #[must_use] + pub fn len(&self) -> usize { + self.inner.read().files.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +/// Substitute the per-host template tokens into an answer file at serve +/// time. Recognised tokens (case-sensitive, double-brace): `{{HOSTNAME}}`, +/// `{{IP}}`, `{{MAC}}`. Unset values render as an empty string so a +/// half-filled profile never leaves a literal `{{IP}}` in the file. +#[must_use] +pub fn render_template( + content: &str, + mac: Option<&str>, + hostname: Option<&str>, + ip: Option<&str>, +) -> String { + content + .replace("{{HOSTNAME}}", hostname.unwrap_or("")) + .replace("{{IP}}", ip.unwrap_or("")) + .replace("{{MAC}}", mac.unwrap_or("")) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn classify_by_extension() { + assert_eq!( + classify(" subiquity.yaml", b""), + UnattendedKind::Autoinstall + ); + assert_eq!(classify("ks.ks", b""), UnattendedKind::Kickstart); + assert_eq!(classify("preseed.seed", b""), UnattendedKind::Preseed); + assert_eq!( + classify("autounattend.xml", b""), + UnattendedKind::AnswerFile + ); + assert_eq!(classify("user-data", b""), UnattendedKind::Autoinstall); + } + + #[test] + fn classify_cfg_by_content() { + assert_eq!( + classify("answer.cfg", b"d-i debian-installer/locale string en_US"), + UnattendedKind::Preseed + ); + assert_eq!( + classify("answer.cfg", b"install\n%packages\n@core\n%end\n"), + UnattendedKind::Kickstart + ); + } + + #[test] + fn accepted_filenames() { + assert!(is_accepted_filename("a.ks")); + assert!(is_accepted_filename("USER-DATA".to_lowercase().as_str())); + assert!(is_accepted_filename("autounattend.XML")); + assert!(!is_accepted_filename("evil.sh")); + assert!(!is_accepted_filename("image.iso")); + } + + #[test] + fn template_substitutes_and_blanks_unset() { + let body = "ip={{IP}} host={{HOSTNAME}} mac={{MAC}}"; + let out = render_template(body, Some("aa:bb"), Some("node1"), None); + assert_eq!(out, "ip= host=node1 mac=aa:bb"); + } + + #[tokio::test] + async fn add_list_read_remove_round_trip() { + let dir = tempdir().unwrap(); + let s = UnattendedStore::new(dir.path().join("unattended")); + let meta = s + .add("rocky.ks", b"install\n%packages\n@core\n%end\n") + .await + .unwrap(); + assert_eq!(meta.kind, UnattendedKind::Kickstart); + assert_eq!(s.len(), 1); + let got = s.read(&meta.id).await.unwrap(); + assert!(got.starts_with(b"install")); + // Survives a reload. + let s2 = UnattendedStore::new(dir.path().join("unattended")); + s2.load_from_disk().await.unwrap(); + assert!(s2.get(&meta.id).is_some()); + assert!(s2.remove(&meta.id).await); + assert!(s2.get(&meta.id).is_none()); + } + + #[tokio::test] + async fn rejects_bad_type_and_oversize() { + let dir = tempdir().unwrap(); + let s = UnattendedStore::new(dir.path().join("unattended")); + assert!(s.add("evil.sh", b"#!/bin/sh").await.is_err()); + let big = vec![b'x'; MAX_UNATTENDED_BYTES + 1]; + assert!(s.add("big.ks", &big).await.is_err()); + } + + #[tokio::test] + async fn ids_are_unique() { + let dir = tempdir().unwrap(); + let s = UnattendedStore::new(dir.path().join("unattended")); + let a = s.add("ks.ks", b"install").await.unwrap(); + let b = s.add("ks.ks", b"install").await.unwrap(); + assert_ne!(a.id, b.id); + } +} diff --git a/crates/openpxe/src/main.rs b/crates/openpxe/src/main.rs index b0ed347..c2ebb2e 100644 --- a/crates/openpxe/src/main.rs +++ b/crates/openpxe/src/main.rs @@ -99,6 +99,16 @@ async fn main() -> anyhow::Result<()> { let iso_store = IsoStore::new(config.paths.iso_dir.clone()); iso_store.load_from_disk().await?; + // v0.5.2: unattended answer-file store (Kickstart/Preseed/Autoinstall/ + // Windows answer files). Separate directory from the ISO store. + let unattended = + openpxe_iso_store::UnattendedStore::new(config.paths.unattended_dir.clone()); + if let Err(e) = unattended.load_from_disk().await { + tracing::warn!( + target: "openpxe::unattended", + "could not load unattended files on startup: {e}" + ); + } let clients = ClientRegistry::new(); let queue = DeploymentQueue::new(); let settings = SettingsStore::load_or_default(&config.paths.work_dir); @@ -174,6 +184,7 @@ async fn main() -> anyhow::Result<()> { smb: Some(smb.clone()), smb_shares: smb_shares.clone(), nfs_shares: nfs_shares.clone(), + unattended: unattended.clone(), uploads: openpxe_http_api::uploads::UploadSessions::default(), log_bus: log_bus.clone(), started_at: time::OffsetDateTime::now_utc(), diff --git a/crates/webui/src/app.css b/crates/webui/src/app.css index 3a49939..fc11c2e 100644 --- a/crates/webui/src/app.css +++ b/crates/webui/src/app.css @@ -840,3 +840,67 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); } border: 1px solid var(--border); color: var(--fg-dim); } + +/* ── v0.5.2: three-slot branding (light / dark / client) ─────────── */ +.logo-slots { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} +@media (max-width: 720px) { .logo-slots { grid-template-columns: 1fr; } } +.logo-slot { + display: flex; flex-direction: column; gap: 8px; + padding: 12px; + background: var(--bg-panel-2); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.logo-slot-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.logo-slot-head .name { color: var(--fg); font-weight: 600; font-size: 13px; } +.logo-slot .swatch { + height: 64px; + display: flex; align-items: center; justify-content: center; + background: var(--bg); border: 1px solid var(--border); + border-radius: var(--radius); +} +.logo-slot .swatch img { max-width: 90%; max-height: 52px; object-fit: contain; } +.logo-slot-hint { color: var(--fg-dim); font-size: 11.5px; } + +/* ── v0.5.2: login local/SSO separation ─────────────────────────── */ +.auth-card .auth-divider { + display: flex; align-items: center; text-align: center; + color: var(--fg-dimmer); font-size: 11px; text-transform: uppercase; + letter-spacing: 0.08em; + margin: 16px 0 12px; +} +.auth-card .auth-divider::before, +.auth-card .auth-divider::after { + content: ""; flex: 1; height: 1px; background: var(--border-soft); +} +.auth-card .auth-divider span { padding: 0 10px; } +.auth-card .sso-block .sso-btn { margin-top: 0; } +.auth-card .sso-btn { + display: flex; align-items: center; justify-content: center; gap: 8px; +} +.auth-card .sso-btn .sso-logo { width: 16px; height: 16px; object-fit: contain; flex: none; } + +/* ── v0.5.2: modal (queue Profile editor) ───────────────────────── */ +.modal-overlay { + position: fixed; inset: 0; z-index: 200; + display: flex; align-items: center; justify-content: center; + background: rgba(0, 0, 0, 0.55); + padding: 24px; +} +.modal-box { + width: 100%; max-width: 520px; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + padding: 22px; +} +.modal-box h2 { margin: 0 0 14px; font-size: 16px; font-weight: 600; color: var(--fg); } +.modal-actions { + display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; +} +.modal-actions .submit { width: auto; padding: 8px 18px; } diff --git a/crates/webui/src/app.js b/crates/webui/src/app.js index 46c4c2d..5ee77a3 100644 --- a/crates/webui/src/app.js +++ b/crates/webui/src/app.js @@ -175,6 +175,83 @@ return { ok: true }; } + // v0.5.2: pretty label for an unattended file's detected kind. + function unattendedKindLabel(k) { + return ({ + kickstart: 'Kickstart', preseed: 'Preseed', autoinstall: 'Autoinstall', + answer_file: 'Answer file', unknown: 'Unknown', + })[k] || (k || 'Unknown'); + } + + // v0.5.2: build the shared "deployment profile" field group — auto + // hostname, auto IP, and an unattended-file picker — reused by the + // Hosts pin form and the Queue "Profile" modal. `files` is the + // /api/unattended list; `profile` seeds the current values. Returns the + // wrapper element plus a `read()` that yields the API body shape. + function buildProfileFields(profile, files, layoutClass) { + profile = profile || {}; + files = files || []; + const hostnameInput = el('input', {type:'text', spellcheck:'false', + placeholder:'e.g. node-7', value: profile.auto_hostname || ''}); + const ipInput = el('input', {type:'text', spellcheck:'false', + placeholder:'e.g. 10.0.0.7', value: profile.auto_ip || ''}); + const sel = el('select', {}, + [el('option', {value:''}, '— none —')].concat( + files.map(f => el('option', {value: f.id}, + f.filename + ' · ' + unattendedKindLabel(f.kind))))); + sel.value = profile.unattended_file || ''; + const wrap = el('div', {class: layoutClass || 'form-row cols-3'}, [ + el('label', {class:'field'}, [ + el('span', {class:'name'}, 'Auto hostname (optional)'), hostnameInput]), + el('label', {class:'field'}, [ + el('span', {class:'name'}, 'Auto IP address (optional)'), ipInput]), + el('label', {class:'field'}, [ + el('span', {class:'name'}, 'Unattended file'), sel]), + ]); + return { + wrap, + read() { + return { + auto_hostname: hostnameInput.value.trim() || null, + auto_ip: ipInput.value.trim() || null, + unattended_file: sel.value || null, + }; + }, + }; + } + + // v0.5.2: minimal modal overlay. `onSave(msgEl)` runs on Save and may + // return a falsy value to keep the modal open (e.g. on validation + // error) or anything truthy to close it. + function openModal(titleText, contentEls, onSave) { + const overlay = el('div', {class:'modal-overlay'}); + const close = () => { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }; + const msg = el('div', {class:'msg', style:'margin-top:10px'}); + const cancelBtn = el('button', {class:'ghost', type:'button', onclick: close}, 'Cancel'); + const saveBtn = el('button', {class:'submit', type:'button'}, 'Save'); + saveBtn.onclick = async () => { + saveBtn.disabled = true; + try { + const ok = await onSave(msg); + if (ok) close(); + } finally { + saveBtn.disabled = false; + } + }; + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + document.addEventListener('keydown', function esc(e) { + if (e.key === 'Escape') { close(); document.removeEventListener('keydown', esc); } + }); + overlay.appendChild(el('div', {class:'modal-box'}, [ + el('h2', {}, titleText), + ...(Array.isArray(contentEls) ? contentEls : [contentEls]), + msg, + el('div', {class:'modal-actions'}, [cancelBtn, saveBtn]), + ])); + document.body.appendChild(overlay); + return { close }; + } + // ── views ──────────────────────────────────────────────────────── const views = { dashboard: async () => { @@ -319,9 +396,11 @@ }, queue: async () => { - const [{ entries = [] }, isos] = await Promise.all([ + const [{ entries = [] }, isos, unattRes] = await Promise.all([ getJSON('/api/queue'), getJSON('/api/isos'), + getJSON('/api/unattended').catch(() => ({ files: [] })), ]); + const unattendedFiles = unattRes.files || []; const targets = isos.flatMap(i => i.boot_entries.map(e => ({ id: e.id, title: e.title + ' — ' + familyLabel(i.introspection.family) }))); @@ -344,21 +423,51 @@ const track = entries.length ? el('div', {class:'queue-track'}, - entries.map(g => el('div', {class:'queue-row' + (g.assigned_target ? ' assigned' : '')}, [ - el('div', {class:'pos'}, '#' + g.position), - el('div', {}, [ - el('div', {class:'mac'}, g.mac), - el('div', {class:'meta'}, - (g.ip ? String(g.ip) + ' · ' : '') + archLabel(g.arch) + ' · joined ' + fmtAgo(g.joined_at)), - ]), - el('div', {}, g.assigned_target - ? el('span', {class:'tag ok'}, '→ ' + g.assigned_target) - : el('span', {class:'tag accent'}, 'waiting')), - el('button', {class:'ghost', onclick: async () => { - await fetch('/api/queue/' + encodeURIComponent(g.id), {method:'DELETE'}); - render('queue'); - }}, 'Release'), - ])) + entries.map(g => { + const prof = g.profile || {}; + const hasProfile = prof.auto_hostname || prof.auto_ip || prof.unattended_file; + const profSummary = hasProfile + ? el('div', {class:'meta', style:'margin-top:2px'}, + '⚙ ' + [ + prof.auto_hostname ? 'host ' + prof.auto_hostname : null, + prof.auto_ip ? 'ip ' + prof.auto_ip : null, + prof.unattended_file ? 'unattended: ' + prof.unattended_file : null, + ].filter(Boolean).join(' · ')) + : null; + return el('div', {class:'queue-row' + (g.assigned_target ? ' assigned' : '')}, [ + el('div', {class:'pos'}, '#' + g.position), + el('div', {}, [ + el('div', {class:'mac'}, g.mac), + el('div', {class:'meta'}, + (g.ip ? String(g.ip) + ' · ' : '') + archLabel(g.arch) + ' · joined ' + fmtAgo(g.joined_at)), + profSummary, + ]), + el('div', {}, g.assigned_target + ? el('span', {class:'tag ok'}, '→ ' + g.assigned_target) + : el('span', {class:'tag accent'}, 'waiting')), + // v0.5.2: per-device deployment profile (auto hostname/IP + + // unattended file), same fields as a Hosts pin. + el('button', {class: hasProfile ? 'accent' : 'ghost', onclick: () => { + const fields = buildProfileFields(prof, unattendedFiles, 'form-row'); + openModal('Deployment profile · ' + g.mac, [ + el('p', {class:'msg', style:'margin-bottom:12px'}, + 'On assignment this device boots with the chosen unattended ' + + 'file; {{HOSTNAME}}/{{IP}}/{{MAC}} are filled into the answer file.'), + fields.wrap, + ], async (msg) => { + const r = await putJSON('/api/queue/' + encodeURIComponent(g.id) + '/profile', fields.read()); + if (r.ok) { render('queue'); return true; } + msg.textContent = 'Save failed: ' + (await r.text()); + msg.className = 'msg err'; + return false; + }); + }}, 'Profile'), + el('button', {class:'ghost', onclick: async () => { + await fetch('/api/queue/' + encodeURIComponent(g.id), {method:'DELETE'}); + render('queue'); + }}, 'Release'), + ]); + }) ) : el('div', {class:'empty'}, 'No clients queued. Boot a client and choose "Queued Deployment" in the PXE menu.'); @@ -399,16 +508,18 @@ // v0.4.67: NFSv3 added back as an in-process Rust client // (nfs3_client crate). Both protocols available side-by-side; // operators pick whichever their NAS prefers. - const [isos, settings, smbRes, nfsRes, disk] = await Promise.all([ + const [isos, settings, smbRes, nfsRes, disk, unattRes] = await Promise.all([ getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/smb-shares'), getJSON('/api/nfs-shares'), getJSON('/api/storage/disk').catch(() => ({ total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?', })), + getJSON('/api/unattended').catch(() => ({ files: [] })), ]); const shares = smbRes.shares || []; const nfsShares = nfsRes.shares || []; + const unattendedFiles = unattRes.files || []; // ── Upload card ── const drop = el('div', {class:'drop', id:'drop'}, [ @@ -712,10 +823,12 @@ shareMsg.className = 'msg err'; }; - // Protocol picker — swaps which field block is visible. + // Protocol picker — swaps which field block is visible. v0.5.2: + // NFS is the default (listed first) — it has no credential fields, + // so the form lands cleaner than the SMB guest/user/password row. const protoSelect = el('select', {}, [ - el('option', {value:'smb'}, 'SMB / CIFS'), el('option', {value:'nfs'}, 'NFS (NFSv3)'), + el('option', {value:'smb'}, 'SMB / CIFS'), ]); // SMB inputs. @@ -857,7 +970,86 @@ const diskCard = diskSpaceCard(disk); - return el('div', {class:'grid'}, [ + // ── Advanced: unattended answer-file upload (v0.5.2) ── + // Mirrors the Settings "Advanced" disclosure. Kickstart / Preseed / + // Autoinstall / Windows answer files land in their own directory + // (never the ISO listing or PXE menu) and are referenced by host + // pins + queue profiles. + const unattMsg = el('div', {class:'msg', style:'margin-top:10px'}); + const unattFile = el('input', { + type:'file', + accept:'.ks,.cfg,.seed,.yaml,.yml,.xml', + style:'display:none', id:'unatt-file', + }); + async function uploadUnattended(f) { + const fd = new FormData(); fd.append('file', f, f.name); + unattMsg.textContent = 'Uploading ' + f.name + ' (' + fmtBytes(f.size) + ')…'; + unattMsg.className = 'msg'; + const r = await fetch('/api/unattended', {method:'POST', body: fd}); + if (r.ok) { + unattMsg.textContent = 'Stored ' + f.name + '.'; + unattMsg.className = 'msg ok'; + render('storage'); + } else { + unattMsg.textContent = 'Upload failed: ' + (await r.text()); + unattMsg.className = 'msg err'; + } + } + const unattDrop = el('div', {class:'drop', id:'unatt-drop'}, [ + el('div', {}, 'Drop a Kickstart, Preseed, Autoinstall, or Answer File here.'), + el('div', {style:'font-size:12px;margin-top:6px'}, + 'Accepted: .ks · .cfg · .seed · .yaml · .yml · .xml (or user-data). ' + + 'Use {{HOSTNAME}}, {{IP}}, {{MAC}} as placeholders — they are filled in per host at boot.'), + ]); + unattDrop.onclick = () => unattFile.click(); + unattDrop.addEventListener('dragover', e => { e.preventDefault(); unattDrop.classList.add('hover'); }); + unattDrop.addEventListener('dragleave', () => unattDrop.classList.remove('hover')); + unattDrop.addEventListener('drop', e => { + e.preventDefault(); unattDrop.classList.remove('hover'); + if (e.dataTransfer.files[0]) uploadUnattended(e.dataTransfer.files[0]); + }); + unattFile.onchange = () => { if (unattFile.files[0]) uploadUnattended(unattFile.files[0]); }; + + const unattRows = unattendedFiles.length + ? unattendedFiles.map(f => el('div', {class:'nfs-row'}, [ + el('span', {class:'dot ok'}), + el('div', {}, [ + el('div', {class:'id'}, [ + el('span', {class:'proto-badge'}, unattendedKindLabel(f.kind)), + document.createTextNode(f.filename), + ]), + el('div', {class:'meta'}, fmtBytes(f.size_bytes) + ' · id ' + f.id), + ]), + el('span'), + el('button', {class:'danger', onclick: async () => { + if (!confirm('Delete unattended file ' + f.filename + '?')) return; + await fetch('/api/unattended/' + encodeURIComponent(f.id), {method:'DELETE'}); + render('storage'); + }}, 'Delete'), + el('span'), + ])) + : [el('div', {class:'empty'}, 'No unattended files yet.')]; + + const unattendedAdvanced = el('details', {class:'advanced-disclosure', style:'margin-top:18px'}, [ + el('summary', {class:'advanced-summary'}, 'Advanced'), + el('div', {class:'card', style:'margin-top:14px'}, [ + el('header', {}, [ + el('h2', {}, 'Unattended file upload'), + el('span', {class:'sub'}, unattendedFiles.length + ' file' + (unattendedFiles.length === 1 ? '' : 's')), + ]), + el('div', {class:'body'}, [ + unattDrop, unattFile, unattMsg, + el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows), + el('p', {class:'msg', style:'margin-top:14px'}, + 'These answer files drive unattended installs. Attach one to a ' + + 'host pin (Hosts tab) or a queued device (Queue → Profile); on ' + + 'boot OpenPXE injects the matching kernel argument and serves the ' + + 'file with the host’s name/IP filled in. Stored separately from ISOs.'), + ]), + ]), + ]); + + return el('div', {}, [el('div', {class:'grid'}, [ diskCard, el('div', {class:'card'}, [ el('header', {}, el('h2', {}, 'Upload ISO')), @@ -883,16 +1075,9 @@ addShare, shareMsg, el('div', {style:'margin-top:18px;display:grid;gap:8px'}, remoteRows), el('p', {class:'msg', style:'margin-top:14px'}, - 'Remote ISO libraries are read on demand — no local cache, no ' + - 'double disk usage. SMB/CIFS is read in userspace via Samba’s ' + - 'smbclient; NFSv3 via a pure-Rust in-process client. Both work in ' + - 'any container (Unraid, OpenShift restricted SCC, plain Docker) with ' + - 'no kernel modules and no CAP_SYS_ADMIN. SMB supports guest or ' + - 'user/password; most NAS appliances expose ISO libraries as ' + - 'guest-readable. NFSv3 auth is AUTH_SYS only — gate access by ' + - 'allowing this OpenPXE host’s IP in the server’s export list. ' + - 'NFS-sourced ISOs also support HTTP Range (seek into a 5 GB ISO ' + - 'without reading what precedes the offset); SMB streams sequentially.'), + 'Remote .iso libraries are read on demand — no local cache to ' + + 'preserve disk usage. Support for NFS 3.0 and SMB. Ensure that ' + + 'the hosts IP address is provisioned.'), ]), ]), el('div', {class:'card'}, [ @@ -902,15 +1087,17 @@ ]), isoTable, ]), - ]); + ]), unattendedAdvanced]); }, hosts: async () => { - const [{ hosts = [] }, isos, bootLogRes] = await Promise.all([ + const [{ hosts = [] }, isos, bootLogRes, unattRes] = await Promise.all([ getJSON('/api/hosts'), getJSON('/api/isos'), getJSON('/api/boot-log').catch(() => ({ events: [] })), + getJSON('/api/unattended').catch(() => ({ files: [] })), ]); const bootEvents = bootLogRes.events || []; + const unattendedFiles = unattRes.files || []; const targets = isos.flatMap(i => i.boot_entries.map(e => ({ id: e.id, title: e.title + ' — ' + familyLabel(i.introspection.family), }))); @@ -929,14 +1116,20 @@ .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'}); + // v0.5.2: optional unattended-install profile — auto hostname, auto + // IP, and an answer-file picker. On boot, a bound MAC with an + // unattended file selected has the right kernel arg injected + // (inst.ks / preseed url / autoinstall ds=nocloud) and the + // hostname/IP templated into the served answer file. + const profileFields = buildProfileFields({}, unattendedFiles, 'form-row cols-3'); 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', { + const r = await postJSON('/api/hosts', Object.assign({ mac: macInput.value, target: targetSel.value, label: labelInput.value, - }); + }, profileFields.read())); if (r.ok) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; render('hosts'); @@ -962,10 +1155,18 @@ } setTimeout(() => { wakeBtn.textContent = original; wakeBtn.disabled = false; }, 2500); }}, 'Wake'); + const autoDeploy = (h.auto_hostname || h.auto_ip || h.unattended_file) + ? el('div', {style:'font-size:12px;line-height:1.5'}, [ + h.unattended_file ? el('div', {}, [el('span', {class:'tag accent'}, 'unattended'), document.createTextNode(' ' + h.unattended_file)]) : null, + h.auto_hostname ? el('div', {class:'mono'}, 'host: ' + h.auto_hostname) : null, + h.auto_ip ? el('div', {class:'mono'}, 'ip: ' + h.auto_ip) : null, + ]) + : el('span', {class:'tag'}, '—'); return 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', {}, autoDeploy), el('td', {}, fmtAgo(h.updated_at)), el('td', {style:'text-align:right;white-space:nowrap'}, [ wakeBtn, @@ -982,7 +1183,8 @@ ? el('table', {}, [ el('thead', {}, el('tr', {}, [ el('th',{},'MAC'), el('th',{},'Label'), - el('th',{},'Target'), el('th',{},'Updated'), el('th',{},''), + el('th',{},'Target'), el('th',{},'Auto-deploy'), + el('th',{},'Updated'), el('th',{},''), ])), el('tbody', {}, rows), ]) @@ -1002,10 +1204,13 @@ 'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'), ]), ]), + el('div', {style:'margin-top:16px'}, profileFields.wrap), upsertBtn, msg, el('p', {class:'msg', style:'margin-top:14px'}, 'When a client with a bound MAC requests boot.ipxe, OpenPXE ' + - 'short-circuits past the interactive menu and chains directly.'), + 'short-circuits past the interactive menu and chains directly. ' + + 'If an unattended file is selected, the matching kernel argument ' + + 'is injected and the hostname/IP are templated into the answer file.'), ]), ]), el('div', {class:'card'}, [ @@ -1188,7 +1393,6 @@ getJSON('/api/notify').catch(() => ({ enabled:false, kind:'slack' })), getJSON('/api/docs').catch(() => ({ groups: [] })), ]); - const hasLogo = !!status.custom_logo; // ── Account card (Forms admin credentials, v0.4.5). // Sonarr/Radarr-style: the admin enters their current password @@ -1332,7 +1536,8 @@ // Hint that used to live under the URL field; surfaced once below // the whole row so it doesn't compete with the in-grid layout. const urlHint = el('p', {class:'msg', style:'margin-top:10px;margin-bottom:0'}, - 'OpenPXE will fetch the metadata URL once SSO sign-in lands; v0.4.63 stores it.'); + 'OpenPXE fetches this metadata URL at sign-in to verify the IdP’s signature. ' + + 'Any IdP-authenticated user gets an operator session.'); const refreshSsoFields = () => { if (ssoMode.value === 'url') { urlWrap.style.display = ''; xmlWrap.style.display = 'none'; @@ -1356,7 +1561,7 @@ const r = await putJSON('/api/sso', payload); if (r.ok) { ssoMsg.textContent = ssoEnabled.checked - ? 'SSO configuration saved. Runtime sign-in flow ships in a future release.' + ? 'SSO saved and live. The login page now shows a “Sign in with …” button.' : 'SSO configuration saved (disabled).'; ssoMsg.className = 'msg ok'; } else { @@ -1371,16 +1576,17 @@ el('span', {class:'sub'}, sso.enabled ? (sso.metadata_url || sso.metadata - ? 'configured · runtime pending' + ? 'live · active' : 'enabled but missing source') : 'disabled'), ]), el('div', {class:'body'}, [ el('p', {class:'msg', style:'margin-bottom:14px'}, - 'Configure your SAML IdP today; OpenPXE persists the metadata so ' + - 'when SSO sign-in lights up in a future release, no operator ' + - 're-entry is needed. The local administrator account above is ' + - 'always available as a fallback owner regardless of SSO state.'), + 'SAML single sign-on is live. With it enabled, the login page shows ' + + 'a “Sign in with …” button that hands off to your IdP; OpenPXE ' + + 'verifies the signed assertion against the IdP metadata and mints an ' + + 'operator session for any authenticated user. The local administrator ' + + 'account above always remains available as a fallback.'), el('label', {class:'check', style:'margin-bottom:14px;max-width:280px'}, [ ssoEnabled, el('span', {}, 'Enable single sign-on'), @@ -1415,72 +1621,79 @@ // referenced by `refreshSsoFields` are attached. refreshSsoFields(); - // ── Custom logo upload. - // Single-file drop-zone; PNG/SVG/JPEG/WebP/GIF up to 2 MB. - // Persisted as /branding/logo. and served from - // /assets/logo.svg in preference to the bundled mark. - const logoFile = el('input', { - type:'file', - accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif', - style:'display:none', id:'logo-file', - }); + // ── Custom logos (v0.5.2). Three independent slots on one row, + // FleetDM-style: Light + Dark feed the WebUI top-left and the form + // login page (whichever theme is active picks its variant); Client + // is the raster painted above the PXE boot menu. Each slot has a + // preview, an upload (PNG/SVG/JPEG/WebP/GIF up to 2 MB; the Client + // slot is raster-only), and a clear. const logoMsg = el('div', {class:'msg', style:'margin-top:10px'}); - const logoBust = '?v=' + Date.now(); // bust the browser cache after upload - logoFile.onchange = async () => { - if (!logoFile.files[0]) return; - const f = logoFile.files[0]; - const fd = new FormData(); fd.append('file', f, f.name); - logoMsg.textContent = 'Uploading ' + f.name + ' (' + fmtBytes(f.size) + ')…'; - logoMsg.className = 'msg'; - const r = await fetch('/api/branding/logo', {method:'POST', body: fd}); - if (r.ok) { - logoMsg.textContent = 'Custom logo installed. Reloading…'; - logoMsg.className = 'msg ok'; - setTimeout(() => location.reload(), 600); - } else { - const t = await r.text(); - logoMsg.textContent = 'Upload failed: ' + t; - logoMsg.className = 'msg err'; - } + const bust = '?v=' + Date.now(); // bust the preview cache after a change + const brandingPresence = status.branding || { light:false, dark:false, client:false }; + const slotDefs = [ + { slot:'light', title:'Light mode', preview:'/assets/logo.svg?theme=light' + '&' + bust.slice(1), + hint:'Shown on light-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' }, + { slot:'dark', title:'Dark mode', preview:'/assets/logo.svg?theme=dark' + '&' + bust.slice(1), + hint:'Shown on dark-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' }, + { slot:'client', title:'Client', preview:'/branding/pxe-logo' + bust, + hint:'Above the PXE boot menu.', accept:'image/png,image/jpeg,image/webp,image/gif' }, + ]; + const slotCol = (def) => { + const set = !!brandingPresence[def.slot]; + const input = el('input', {type:'file', accept:def.accept, style:'display:none'}); + input.onchange = async () => { + if (!input.files[0]) return; + const f = input.files[0]; + const fd = new FormData(); fd.append('file', f, f.name); + logoMsg.textContent = 'Uploading ' + def.title + ' logo (' + fmtBytes(f.size) + ')…'; + logoMsg.className = 'msg'; + const r = await fetch('/api/branding/logo/' + def.slot, {method:'POST', body: fd}); + if (r.ok) { + logoMsg.textContent = def.title + ' logo installed. Reloading…'; + logoMsg.className = 'msg ok'; + setTimeout(() => location.reload(), 600); + } else { + logoMsg.textContent = 'Upload failed: ' + (await r.text()); + logoMsg.className = 'msg err'; + } + }; + return el('div', {class:'logo-slot'}, [ + el('div', {class:'logo-slot-head'}, [ + el('span', {class:'name'}, def.title), + set ? el('span', {class:'tag ok'}, 'set') : el('span', {class:'tag'}, 'default'), + ]), + el('div', {class:'swatch', style: def.slot === 'light' ? 'background:#f4f5f7' : ''}, + el('img', {src: def.preview, alt: def.title + ' logo'})), + el('div', {class:'logo-slot-hint'}, def.hint), + el('div', {style:'display:flex;gap:6px;flex-wrap:wrap'}, [ + el('button', {class:'ghost', onclick: () => input.click()}, set ? 'Replace' : 'Upload'), + set ? el('button', {class:'danger', onclick: async () => { + if (!confirm('Remove the ' + def.title + ' logo?')) return; + const r = await fetch('/api/branding/logo/' + def.slot, {method:'DELETE'}); + if (r.ok || r.status === 204) { + logoMsg.textContent = def.title + ' logo cleared. Reloading…'; + logoMsg.className = 'msg ok'; + setTimeout(() => location.reload(), 500); + } else { + logoMsg.textContent = 'Clear failed: ' + (await r.text()); + logoMsg.className = 'msg err'; + } + }}, 'Remove') : null, + ]), + input, + ]); }; const logoCard = el('div', {class:'card'}, [ el('header', {}, el('h2', {}, 'Branding')), el('div', {class:'body'}, [ el('p', {class:'msg', style:'margin-bottom:14px'}, - 'Override the top-left brand mark with your own logo. Up to 2 MB; ' + - 'PNG, SVG, JPEG, WebP, or GIF. The original OpenPXE version is ' + - 'always shown in the bottom-left for support purposes.'), - el('div', {class:'logo-preview'}, [ - el('div', {class:'swatch'}, - el('img', {src: '/assets/logo.svg' + logoBust, alt:'current logo'})), - el('div', {class:'info'}, [ - el('div', {class:'name'}, hasLogo ? 'Custom logo (uploaded)' : 'Default OpenPXE mark'), - el('div', {class:'meta'}, - hasLogo - ? 'Operator-uploaded; served from /branding/.' - : 'Bundled rainbow-horizon mark. Upload an image to override.'), - ]), - el('div', {style:'display:flex;gap:8px;flex-wrap:wrap'}, [ - el('button', {onclick: () => logoFile.click()}, - hasLogo ? 'Replace logo' : 'Upload logo'), - hasLogo - ? el('button', {class:'danger', onclick: async () => { - if (!confirm('Remove custom logo and revert to the OpenPXE mark?')) return; - const r = await fetch('/api/branding/logo', {method:'DELETE'}); - if (r.ok || r.status === 204) { - logoMsg.textContent = 'Reverted to default mark. Reloading…'; - logoMsg.className = 'msg ok'; - setTimeout(() => location.reload(), 500); - } else { - const t = await r.text(); - logoMsg.textContent = 'Clear failed: ' + t; - logoMsg.className = 'msg err'; - } - }}, 'Remove') - : null, - ]), - ]), - logoFile, logoMsg, + 'Upload your own brand marks. Up to 2 MB each; PNG, SVG, JPEG, ' + + 'WebP, or GIF (the Client logo must be a raster). The Light and Dark ' + + 'marks appear in the top-left and on the sign-in page depending on ' + + 'theme; the Client mark sits above the PXE boot menu. The favicon ' + + 'and the version string in the bottom-left always stay OpenPXE.'), + el('div', {class:'logo-slots'}, slotDefs.map(slotCol)), + logoMsg, ]), ]); @@ -1763,11 +1976,23 @@ function applyTheme(theme) { document.documentElement.setAttribute('data-theme', theme); try { localStorage.setItem('openpxe-theme', theme); } catch {} + applyBrandLogos(theme); } function currentTheme() { return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark'; } + // v0.5.2: point the sidebar + login brand marks at the theme's logo + // slot so a light/dark toggle swaps the logo too (FleetDM-style). + function applyBrandLogos(theme) { + theme = theme || currentTheme(); + const rev = (brandInfo && brandInfo.logo_rev) || 0; + const url = '/assets/logo.svg?theme=' + theme + '&r=' + rev; + document.querySelectorAll('.sidebar .brand img, .brand-row img').forEach(img => { + img.src = url; + }); + } document.addEventListener('DOMContentLoaded', () => { + applyBrandLogos(); const btn = $('#theme-toggle'); if (btn) { btn.addEventListener('click', () => { @@ -1868,7 +2093,7 @@ // logo spans the card, no "OpenPXE" wordmark (the logo is the brand). // Default: bundled mark + "OpenPXE". function authBrandRow() { - const src = '/assets/logo.svg?r=' + (brandInfo.logo_rev || 0); + const src = '/assets/logo.svg?theme=' + currentTheme() + '&r=' + (brandInfo.logo_rev || 0); if (brandInfo.has_custom_logo) { return el('div', {class:'brand-row has-custom-logo'}, [ el('img', {src, alt:'logo'}), @@ -1908,18 +2133,29 @@ window.history.replaceState({}, '', window.location.pathname); } - const ssoButton = ssoConfig && ssoConfig.enabled && (ssoConfig.metadata_url || ssoConfig.metadata) - ? el('button', {type:'button', class:'sso-btn', onclick: () => { - // SP-initiated SAML login (v0.5.1): hand off to the IdP. The - // /api/sso/acs endpoint verifies the response, mints the - // operator session, and redirects back to the dashboard. - window.location.assign('/api/sso/login'); - }}, [ - el('div', {}, 'Sign in with ' + (ssoConfig.idp_name || 'SSO')), + // v0.5.2: FleetDM-style separation. The local credential form is its + // own self-contained
; when SSO is enabled, a distinct + // "Sign in with …" button sits below a divider — the credential + // fields no longer double as the SSO trigger. + const ssoLive = ssoConfig && ssoConfig.enabled && (ssoConfig.metadata_url || ssoConfig.metadata); + const ssoBlock = ssoLive + ? el('div', {class:'sso-block'}, [ + el('div', {class:'auth-divider'}, el('span', {}, 'or')), + el('button', {type:'button', class:'sso-btn', onclick: () => { + // SP-initiated SAML login: hand off to the IdP. /api/sso/acs + // verifies the response, mints the operator session, and + // redirects back to the dashboard. + window.location.assign('/api/sso/login'); + }}, [ + ssoConfig.idp_logo_url + ? el('img', {class:'sso-logo', src: ssoConfig.idp_logo_url, alt:'', onerror: function(){ this.style.display='none'; }}) + : null, + el('span', {}, 'Sign in with ' + (ssoConfig.idp_name || 'SSO')), + ]), ]) : null; - const form = el('form', {class:'auth-form', onsubmit: async (e) => { + const form = el('form', {class:'auth-form local-login', onsubmit: async (e) => { e.preventDefault(); err.style.display = 'none'; submit.disabled = true; @@ -1947,9 +2183,6 @@ submit.textContent = 'Sign in'; } }}, [ - authBrandRow(), - el('h2', {}, 'Sign in'), - el('p', {class:'lede'}, 'Enter your administrator credentials. Forgot them? SSH to the host and remove work_dir/auth.json — the next launch will re-prompt for setup.'), el('label', {class:'field'}, [ el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Username'), usernameInput, @@ -1959,11 +2192,16 @@ passwordInput, ]), submit, - ssoButton, + ]); + return el('div', {class:'login-stack'}, [ + authBrandRow(), + el('h2', {}, 'Sign in'), + el('p', {class:'lede'}, 'Enter your administrator credentials. Forgot them? SSH to the host and remove work_dir/auth.json — the next launch will re-prompt for setup.'), + form, + ssoBlock, err, el('div', {class:'auth-foot'}, 'OpenPXE · ' + (window.location.host || '')), ]); - return form; } function buildSetupCard() { diff --git a/crates/webui/src/index.html b/crates/webui/src/index.html index 6b91256..5b284b9 100644 --- a/crates/webui/src/index.html +++ b/crates/webui/src/index.html @@ -13,7 +13,10 @@ on the asset handlers, the practical caching window is one version. --> - + +