diff --git a/Cargo.lock b/Cargo.lock index f38d712..88d7989 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1015,7 +1015,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openpxe" -version = "0.4.4" +version = "0.4.5" dependencies = [ "anyhow", "axum", @@ -1037,9 +1037,10 @@ dependencies = [ [[package]] name = "openpxe-core" -version = "0.4.4" +version = "0.4.5" dependencies = [ "anyhow", + "bcrypt", "parking_lot", "serde", "serde_json", @@ -1055,7 +1056,7 @@ dependencies = [ [[package]] name = "openpxe-dhcp-proxy" -version = "0.4.4" +version = "0.4.5" dependencies = [ "anyhow", "bytes", @@ -1069,7 +1070,7 @@ dependencies = [ [[package]] name = "openpxe-http-api" -version = "0.4.4" +version = "0.4.5" dependencies = [ "anyhow", "axum", @@ -1082,6 +1083,7 @@ dependencies = [ "openpxe-ipxe-assets", "openpxe-iso-store", "openpxe-webui", + "parking_lot", "serde", "serde_json", "tempfile", @@ -1098,7 +1100,7 @@ dependencies = [ [[package]] name = "openpxe-ipxe-assets" -version = "0.4.4" +version = "0.4.5" dependencies = [ "openpxe-core", "rust-embed", @@ -1108,7 +1110,7 @@ dependencies = [ [[package]] name = "openpxe-iso-store" -version = "0.4.4" +version = "0.4.5" dependencies = [ "anyhow", "bcrypt", @@ -1131,7 +1133,7 @@ dependencies = [ [[package]] name = "openpxe-tftp" -version = "0.4.4" +version = "0.4.5" dependencies = [ "anyhow", "bytes", @@ -1145,7 +1147,7 @@ dependencies = [ [[package]] name = "openpxe-webui" -version = "0.4.4" +version = "0.4.5" [[package]] name = "parking_lot" diff --git a/Cargo.toml b/Cargo.toml index c94d7c2..0013d78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.4.4" +version = "0.4.5" edition = "2021" rust-version = "1.95" license = "MIT OR Apache-2.0" diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 108d11f..3994901 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -21,6 +21,9 @@ time.workspace = true uuid.workspace = true parking_lot.workspace = true tokio = { workspace = true, features = ["sync", "rt", "macros", "time"] } +# bcrypt for the admin Forms auth (v0.4.5). Already in the workspace +# for per-ISO boot passwords; just re-exported here. +bcrypt.workspace = true [dev-dependencies] tempfile = "3.12" diff --git a/crates/core/src/auth.rs b/crates/core/src/auth.rs new file mode 100644 index 0000000..f8255a5 --- /dev/null +++ b/crates/core/src/auth.rs @@ -0,0 +1,353 @@ +//! Operator authentication — Sonarr/Radarr-style single-admin Forms model. +//! +//! On a fresh install, no admin account exists; the WebUI's first-run +//! flow prompts the operator to create one. After that the chosen +//! credentials gate `/api/*` access. The admin can rotate username + +//! password from Settings → Account. +//! +//! Multi-user RBAC isn't a goal for OpenPXE — the user explicitly asked +//! for "you have access or you don't". When SSO is configured, additional +//! users come in through the IdP; the locally-stored admin is the +//! fallback owner who can change SSO config or the seal-breaker for an +//! IdP outage. So one record is enough. +//! +//! Storage policy mirrors [`crate::host_bindings::HostBindings`] and +//! [`crate::boot_log::BootLog`]: in-memory authoritative; disk is the +//! crash-survival cache; a corrupt `auth.json` falls back to "no admin +//! configured" rather than blocking startup, which puts the UI back +//! into setup mode rather than locking the operator out. + +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::Arc; +use time::OffsetDateTime; + +use crate::{Error, Result}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdminAccount { + pub username: String, + /// bcrypt hash (cost 10). The plaintext password never leaves the + /// request that set it — same discipline as the per-ISO boot password. + pub password_hash: String, + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub updated_at: OffsetDateTime, +} + +/// Public projection — no hash, safe to ship to the WebUI. +#[derive(Debug, Clone, Serialize)] +pub struct AdminPublic { + pub username: String, + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub updated_at: OffsetDateTime, +} + +impl From<&AdminAccount> for AdminPublic { + fn from(a: &AdminAccount) -> Self { + Self { + username: a.username.clone(), + created_at: a.created_at, + updated_at: a.updated_at, + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct Inner { + admin: Option, +} + +/// In-memory + on-disk admin registry. Cheap to clone. +#[derive(Debug, Clone)] +pub struct AdminStore { + path: Arc, + inner: Arc>, +} + +impl AdminStore { + /// Load from `/auth.json`, or start empty. A bad file + /// logs a warning and falls back to "no admin configured" — better + /// to surface the setup flow than lock the operator out of their + /// own install. + #[must_use] + pub fn load_or_default(work_dir: &std::path::Path) -> Self { + let path = work_dir.join("auth.json"); + let inner = match std::fs::read_to_string(&path) { + Ok(text) => match serde_json::from_str::(&text) { + Ok(parsed) => parsed, + Err(e) => { + tracing::warn!( + target: "openpxe::auth", + "auth.json present but unreadable ({e}); starting in setup mode" + ); + Inner::default() + } + }, + Err(_) => Inner::default(), + }; + Self { + path: Arc::new(path), + inner: Arc::new(RwLock::new(inner)), + } + } + + /// Has an admin been bootstrapped? Drives the first-run / login + /// fork in the HTTP layer. + #[must_use] + pub fn is_configured(&self) -> bool { + self.inner.read().admin.is_some() + } + + /// Public-safe snapshot for the WebUI. + #[must_use] + pub fn snapshot(&self) -> Option { + self.inner.read().admin.as_ref().map(AdminPublic::from) + } + + /// First-run setup: create the admin account. Fails if one already + /// exists — the HTTP layer surfaces that as 409. + pub fn bootstrap(&self, username: &str, password: &str) -> Result { + validate_username(username)?; + validate_password(password)?; + let hash = bcrypt_hash(password)?; + let now = OffsetDateTime::now_utc(); + let admin = AdminAccount { + username: username.trim().to_string(), + password_hash: hash, + created_at: now, + updated_at: now, + }; + { + let mut g = self.inner.write(); + if g.admin.is_some() { + return Err(Error::Invalid( + "admin account already configured".into(), + )); + } + g.admin = Some(admin.clone()); + } + self.persist(); + tracing::info!( + target: "openpxe::auth", + username = %admin.username, + "admin account created (first-run setup)" + ); + Ok((&admin).into()) + } + + /// Verify credentials. Returns the admin record (public projection) + /// on success, `Ok(None)` on mismatch, `Err` on systemic bcrypt + /// failure (treated as "auth not available right now" by callers). + pub fn verify(&self, username: &str, password: &str) -> Result> { + let Some(admin) = self.inner.read().admin.clone() else { + return Ok(None); + }; + if username.trim() != admin.username { + return Ok(None); + } + // bcrypt compares in constant time relative to the same hash. + // Doing the username check first is fine — a username mismatch + // returns immediately, but the only thing leaked is "this isn't + // the admin's username" which the operator already knows. + match bcrypt::verify(password, &admin.password_hash) { + Ok(true) => Ok(Some((&admin).into())), + Ok(false) => Ok(None), + Err(e) => Err(Error::Other(e.into())), + } + } + + /// Rotate username and/or password. `current_password` must match + /// the *existing* hash — same flow as Sonarr's "current password + /// required to change". `new_username`/`new_password` are optional: + /// pass only what you want to change. + pub fn update_credentials( + &self, + current_password: &str, + new_username: Option<&str>, + new_password: Option<&str>, + ) -> Result { + // Re-check ownership before any state mutation. + let existing = self + .inner + .read() + .admin + .clone() + .ok_or_else(|| Error::Invalid("no admin configured".into()))?; + match bcrypt::verify(current_password, &existing.password_hash) { + Ok(true) => {} + Ok(false) => return Err(Error::Invalid("current password is incorrect".into())), + Err(e) => return Err(Error::Other(e.into())), + } + + let mut updated = existing.clone(); + if let Some(u) = new_username { + validate_username(u)?; + updated.username = u.trim().to_string(); + } + if let Some(p) = new_password { + validate_password(p)?; + updated.password_hash = bcrypt_hash(p)?; + } + updated.updated_at = OffsetDateTime::now_utc(); + + { + let mut g = self.inner.write(); + g.admin = Some(updated.clone()); + } + self.persist(); + tracing::info!( + target: "openpxe::auth", + username = %updated.username, + "admin credentials updated" + ); + Ok((&updated).into()) + } + + fn persist(&self) { + let snap = self.inner.read().clone(); + let body = match serde_json::to_vec_pretty(&snap) { + Ok(b) => b, + Err(e) => { + tracing::warn!(target: "openpxe::auth", "serialize auth.json: {e}"); + return; + } + }; + if let Some(parent) = self.path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let tmp = self.path.with_extension("json.tmp"); + if let Err(e) = std::fs::write(&tmp, body) { + tracing::warn!(target: "openpxe::auth", "write auth.json tmp: {e}"); + return; + } + if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) { + tracing::warn!(target: "openpxe::auth", "rename auth.json: {e}"); + } + } +} + +fn validate_username(u: &str) -> Result<()> { + let u = u.trim(); + if u.is_empty() { + return Err(Error::Invalid("username must not be empty".into())); + } + if u.len() > 64 { + return Err(Error::Invalid("username must be 64 chars or fewer".into())); + } + if !u.chars().all(|c| c.is_ascii_graphic() && c != ':') { + return Err(Error::Invalid( + "username must be ASCII printable with no ':' character".into(), + )); + } + Ok(()) +} + +fn validate_password(p: &str) -> Result<()> { + if p.len() < 8 { + return Err(Error::Invalid( + "password must be at least 8 characters".into(), + )); + } + if p.len() > 256 { + return Err(Error::Invalid( + "password must be 256 characters or fewer".into(), + )); + } + Ok(()) +} + +fn bcrypt_hash(password: &str) -> Result { + bcrypt::hash(password, bcrypt::DEFAULT_COST).map_err(|e| Error::Other(e.into())) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn empty_after_load_when_no_file() { + let dir = tempdir().unwrap(); + let s = AdminStore::load_or_default(dir.path()); + assert!(!s.is_configured()); + assert!(s.snapshot().is_none()); + } + + #[test] + fn bootstrap_then_verify_round_trip() { + let dir = tempdir().unwrap(); + let s = AdminStore::load_or_default(dir.path()); + let pub_ = s.bootstrap("admin", "hunter2hunter2").unwrap(); + assert_eq!(pub_.username, "admin"); + assert!(s.is_configured()); + + // Correct creds match; wrong creds don't. + assert!(s.verify("admin", "hunter2hunter2").unwrap().is_some()); + assert!(s.verify("admin", "wrong").unwrap().is_none()); + assert!(s.verify("nobody", "hunter2hunter2").unwrap().is_none()); + } + + #[test] + fn bootstrap_rejects_second_call() { + let dir = tempdir().unwrap(); + let s = AdminStore::load_or_default(dir.path()); + s.bootstrap("admin", "hunter2hunter2").unwrap(); + let r = s.bootstrap("other", "anotherpass1"); + assert!(matches!(r, Err(Error::Invalid(_)))); + } + + #[test] + fn round_trip_survives_disk_reload() { + let dir = tempdir().unwrap(); + let s = AdminStore::load_or_default(dir.path()); + s.bootstrap("admin", "hunter2hunter2").unwrap(); + drop(s); + let s2 = AdminStore::load_or_default(dir.path()); + assert!(s2.is_configured()); + assert!(s2.verify("admin", "hunter2hunter2").unwrap().is_some()); + } + + #[test] + fn update_credentials_requires_current_password() { + let dir = tempdir().unwrap(); + let s = AdminStore::load_or_default(dir.path()); + s.bootstrap("admin", "hunter2hunter2").unwrap(); + // Wrong current password → no change. + let r = s.update_credentials("nope", None, Some("newpassword1")); + assert!(matches!(r, Err(Error::Invalid(_)))); + assert!(s.verify("admin", "hunter2hunter2").unwrap().is_some()); + + // Correct current password rotates only what's supplied. + s.update_credentials("hunter2hunter2", Some("alice"), Some("newpassword1")) + .unwrap(); + assert!(s.verify("admin", "hunter2hunter2").unwrap().is_none()); + assert!(s.verify("alice", "newpassword1").unwrap().is_some()); + } + + #[test] + fn update_credentials_partial_password_only_keeps_username() { + let dir = tempdir().unwrap(); + let s = AdminStore::load_or_default(dir.path()); + s.bootstrap("admin", "hunter2hunter2").unwrap(); + s.update_credentials("hunter2hunter2", None, Some("newpassword1")) + .unwrap(); + assert!(s.verify("admin", "newpassword1").unwrap().is_some()); + } + + #[test] + fn validates_username_and_password() { + let dir = tempdir().unwrap(); + let s = AdminStore::load_or_default(dir.path()); + assert!(s.bootstrap("", "hunter2hunter2").is_err()); + assert!(s.bootstrap("ad:min", "hunter2hunter2").is_err()); // ':' reserved + assert!(s.bootstrap("admin", "short").is_err()); // <8 chars + // 65-char username is too long. + let long = "a".repeat(65); + assert!(s.bootstrap(&long, "hunter2hunter2").is_err()); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index dc11f17..2efb705 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -3,6 +3,7 @@ #![forbid(unsafe_code)] pub mod arch; +pub mod auth; pub mod boot_log; pub mod branding; pub mod client; @@ -13,11 +14,14 @@ pub mod log_bus; pub mod metrics; pub mod queue; pub mod settings; +pub mod sso; 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 client::{ClientEvent, ClientRegistry, ClientSnapshot}; +pub use sso::{SsoConfig, SsoStore}; pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig}; pub use error::{Error, Result}; pub use host_bindings::{normalize_mac, HostBinding, HostBindings}; diff --git a/crates/core/src/sso.rs b/crates/core/src/sso.rs new file mode 100644 index 0000000..bf8afcf --- /dev/null +++ b/crates/core/src/sso.rs @@ -0,0 +1,267 @@ +//! SAML SSO configuration — FleetDM-shaped, storage-only for v0.4.5. +//! +//! The operator pastes their IdP's metadata XML (or its URL) and a +//! human-readable label; v0.4.5 just persists it. The actual SAML +//! response-validation / JIT-provisioning flow lands in a later release +//! — for now we cover the "configurable" half so an operator can teach +//! OpenPXE about their IdP today and flip the switch on next upgrade. +//! +//! Shape borrowed from 's app-config +//! SSO block, minus the user-RBAC fields (OpenPXE is single-tier: you +//! have access or you don't). Entity ID is omitted from the operator +//! UI per the v0.4.5 brief — it defaults to the advertised public base +//! URL when SAML wiring lands, which is what most IdPs expect anyway. + +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::Arc; + +use crate::{Error, Result}; + +/// The configurable surface. `metadata` and `metadata_url` are mutually +/// exclusive at apply time (one or the other identifies the IdP); the +/// store keeps both fields so an operator can switch between them +/// without losing the inactive one. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SsoConfig { + /// Master switch — when false, all SSO machinery (planned for a + /// later release) is skipped regardless of the rest of the fields. + #[serde(default)] + pub enabled: bool, + /// Display name shown on the WebUI's login screen as the "Sign in + /// with X" button label. Empty/whitespace falls back to "SSO". + #[serde(default)] + pub idp_name: String, + /// Raw SAML metadata XML pasted by the operator. Mutually exclusive + /// with `metadata_url`; if both are set, the URL wins at apply time + /// (operators typically forget about a stale XML paste). + #[serde(default)] + pub metadata: String, + /// HTTPS URL where the IdP serves its metadata. Loaded lazily by the + /// future SAML flow; not validated here beyond a basic length cap. + #[serde(default)] + pub metadata_url: String, +} + +impl SsoConfig { + /// Returns `true` only when the config is *usable* — enabled, and + /// at least one of metadata/metadata_url is present. The future + /// login flow will key off this; for v0.4.5 the WebUI uses it to + /// surface a yellow "configured but not live yet" hint. + #[must_use] + pub fn is_usable(&self) -> bool { + self.enabled + && (!self.metadata.trim().is_empty() || !self.metadata_url.trim().is_empty()) + } +} + +/// In-memory + on-disk SSO settings registry. +#[derive(Debug, Clone)] +pub struct SsoStore { + path: Arc, + inner: Arc>, +} + +impl SsoStore { + /// Load from `/sso.json`, or start with the default empty + /// (`enabled = false`) config. A corrupt file falls back to default + /// rather than blocking startup. + #[must_use] + pub fn load_or_default(work_dir: &std::path::Path) -> Self { + let path = work_dir.join("sso.json"); + let cfg = match std::fs::read_to_string(&path) { + Ok(text) => match serde_json::from_str::(&text) { + Ok(parsed) => parsed, + Err(e) => { + tracing::warn!( + target: "openpxe::sso", + "sso.json present but unreadable ({e}); starting with default config" + ); + SsoConfig::default() + } + }, + Err(_) => SsoConfig::default(), + }; + Self { + path: Arc::new(path), + inner: Arc::new(RwLock::new(cfg)), + } + } + + #[must_use] + pub fn snapshot(&self) -> SsoConfig { + self.inner.read().clone() + } + + /// Replace the whole config in one shot. Light validation: metadata + /// XML and URL are length-capped so an operator can't OOM us by + /// pasting a 10 GiB blob; the IdP UI tab clamps the input visually, + /// but the server enforces a hard ceiling regardless. + pub fn replace(&self, mut cfg: SsoConfig) -> Result { + cfg.idp_name = cfg.idp_name.trim().to_string(); + cfg.metadata = cfg.metadata.trim().to_string(); + cfg.metadata_url = cfg.metadata_url.trim().to_string(); + if cfg.metadata.len() > MAX_METADATA_BYTES { + return Err(Error::Invalid(format!( + "metadata XML exceeds {MAX_METADATA_BYTES}-byte cap" + ))); + } + if cfg.metadata_url.len() > MAX_URL_LEN { + return Err(Error::Invalid(format!( + "metadata_url exceeds {MAX_URL_LEN}-char cap" + ))); + } + if !cfg.metadata_url.is_empty() + && !cfg.metadata_url.starts_with("http://") + && !cfg.metadata_url.starts_with("https://") + { + return Err(Error::Invalid( + "metadata_url must start with http:// or https://".into(), + )); + } + // If they're trying to *enable* the integration but haven't + // supplied either source, reject — saves a "configured but + // unusable" surprise later. + if cfg.enabled && cfg.metadata.is_empty() && cfg.metadata_url.is_empty() { + return Err(Error::Invalid( + "enable SSO requires either metadata XML or a metadata URL".into(), + )); + } + { + let mut g = self.inner.write(); + *g = cfg.clone(); + } + self.persist(); + tracing::info!( + target: "openpxe::sso", + enabled = cfg.enabled, + idp = %cfg.idp_name, + has_xml = !cfg.metadata.is_empty(), + has_url = !cfg.metadata_url.is_empty(), + "sso configuration updated" + ); + Ok(cfg) + } + + fn persist(&self) { + let snap = self.inner.read().clone(); + let body = match serde_json::to_vec_pretty(&snap) { + Ok(b) => b, + Err(e) => { + tracing::warn!(target: "openpxe::sso", "serialize sso.json: {e}"); + return; + } + }; + if let Some(parent) = self.path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let tmp = self.path.with_extension("json.tmp"); + if let Err(e) = std::fs::write(&tmp, body) { + tracing::warn!(target: "openpxe::sso", "write sso.json tmp: {e}"); + return; + } + if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) { + tracing::warn!(target: "openpxe::sso", "rename sso.json: {e}"); + } + } +} + +/// Saturation caps. The numbers are generous for any real IdP metadata +/// document — Okta's largest is ~50 KB, Azure AD's ~30 KB. +const MAX_METADATA_BYTES: usize = 1024 * 1024; +const MAX_URL_LEN: usize = 2048; + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn default_is_disabled_and_empty() { + let dir = tempdir().unwrap(); + let s = SsoStore::load_or_default(dir.path()); + let cfg = s.snapshot(); + assert!(!cfg.enabled); + assert!(cfg.metadata.is_empty()); + assert!(cfg.metadata_url.is_empty()); + assert!(!cfg.is_usable()); + } + + #[test] + fn replace_metadata_url_round_trip_via_disk() { + let dir = tempdir().unwrap(); + let s = SsoStore::load_or_default(dir.path()); + s.replace(SsoConfig { + enabled: true, + idp_name: "Okta".into(), + metadata: String::new(), + metadata_url: "https://idp.example.com/metadata".into(), + }) + .unwrap(); + drop(s); + let s2 = SsoStore::load_or_default(dir.path()); + let cfg = s2.snapshot(); + assert!(cfg.enabled); + assert!(cfg.is_usable()); + assert_eq!(cfg.idp_name, "Okta"); + assert_eq!(cfg.metadata_url, "https://idp.example.com/metadata"); + } + + #[test] + fn replace_xml_paste_is_accepted() { + let dir = tempdir().unwrap(); + let s = SsoStore::load_or_default(dir.path()); + let xml = r#"test"#; + s.replace(SsoConfig { + enabled: true, + idp_name: "Test IdP".into(), + metadata: xml.into(), + metadata_url: String::new(), + }) + .unwrap(); + assert!(s.snapshot().is_usable()); + } + + #[test] + fn enable_without_source_is_rejected() { + let dir = tempdir().unwrap(); + let s = SsoStore::load_or_default(dir.path()); + let r = s.replace(SsoConfig { + enabled: true, + idp_name: "Okta".into(), + metadata: String::new(), + metadata_url: String::new(), + }); + assert!(matches!(r, Err(Error::Invalid(_)))); + // …and a disabled blank config is fine. + s.replace(SsoConfig::default()).unwrap(); + } + + #[test] + fn metadata_url_must_be_http_scheme() { + let dir = tempdir().unwrap(); + let s = SsoStore::load_or_default(dir.path()); + let r = s.replace(SsoConfig { + enabled: false, + idp_name: String::new(), + metadata: String::new(), + metadata_url: "ftp://idp.example.com/metadata".into(), + }); + assert!(matches!(r, Err(Error::Invalid(_)))); + } + + #[test] + fn metadata_size_cap_enforced() { + let dir = tempdir().unwrap(); + let s = SsoStore::load_or_default(dir.path()); + let oversize = "a".repeat(MAX_METADATA_BYTES + 1); + let r = s.replace(SsoConfig { + enabled: false, + idp_name: String::new(), + metadata: oversize, + metadata_url: String::new(), + }); + assert!(matches!(r, Err(Error::Invalid(_)))); + } +} diff --git a/crates/http-api/Cargo.toml b/crates/http-api/Cargo.toml index 0969d47..05f54da 100644 --- a/crates/http-api/Cargo.toml +++ b/crates/http-api/Cargo.toml @@ -32,6 +32,8 @@ futures.workspace = true mime.workspace = true mime_guess.workspace = true uuid.workspace = true +# v0.4.5 Forms auth: lock-free session store and cookie helpers. +parking_lot.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] } diff --git a/crates/http-api/src/app.rs b/crates/http-api/src/app.rs index baa4643..ca493aa 100644 --- a/crates/http-api/src/app.rs +++ b/crates/http-api/src/app.rs @@ -13,6 +13,7 @@ //! | `/iso//*` | Files inside the ISO (for wimboot & kernel/initrd) | //! | `/api/*` | JSON/HTML API for the web UI | +use crate::auth as auth_api; use crate::ipxe_script::{ render_entry, render_family_menu, render_local_hdd, render_menu, render_nic_info, render_queue_entry, render_shell, render_tools_menu, render_util, @@ -30,7 +31,8 @@ use axum::{ Json, Router, }; use openpxe_core::{ - ext_for_mime, BootEvent, ClientEvent, Error, Settings, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES, + ext_for_mime, BootEvent, ClientEvent, Error, Settings, SsoConfig, ALLOWED_LOGO_MIMES, + MAX_LOGO_BYTES, }; use openpxe_ipxe_assets::asset_bytes; use openpxe_iso_store::{IsoCategory, IsoMeta, NfsAddRequest}; @@ -97,6 +99,24 @@ pub fn build_router(state: AppState) -> Router { // under the Settings tab — operators chasing an integration get // it in-product instead of having to fetch the OpenAPI YAML. .route("/api/docs", get(api_docs)) + // v0.4.5: Sonarr/Radarr-style admin Forms auth. First-run + // /setup creates the single admin account; /login validates; + // /logout revokes the session; /me powers the front-end's + // "should I show the setup page, the login page, or the + // dashboard?" decision. /me/credentials rotates the admin's + // username/password. + .route("/api/setup", post(auth_api::api_setup)) + .route("/api/login", post(auth_api::api_login)) + .route("/api/logout", post(auth_api::api_logout)) + .route("/api/me", get(auth_api::api_me)) + .route( + "/api/me/credentials", + put(auth_api::api_update_credentials), + ) + // v0.4.5: SAML SSO configuration (FleetDM-shaped, storage-only). + // The actual sign-in flow lands in a later release; this just + // gives operators a place to paste their IdP metadata today. + .route("/api/sso", get(api_sso_get).put(api_sso_put)) .route("/api/clients", get(api_list_clients)) .route("/api/status", get(api_status)) .route("/api/settings", get(api_get_settings).put(api_put_settings)) @@ -128,12 +148,41 @@ pub fn build_router(state: AppState) -> Router { // format. No auth — the metrics surface is intentionally // boring (counts, no payloads). .route("/metrics", get(api_metrics)) + // v0.4.5: Forms-auth middleware. Layered *after* `.route(...)` + // calls so it applies uniformly; passes everything through when + // no admin is configured (tests + fresh installs ride this path). + // The allowlist inside `auth_api::require_auth` keeps PXE-essential + // endpoints reachable for iPXE clients that can't authenticate. + .layer(axum::middleware::from_fn_with_state( + state.clone(), + auth_api::require_auth, + )) .layer(TraceLayer::new_for_http()) // 16 GiB upload cap — ISOs are big; chunks stream so this isn't memory use. .layer(DefaultBodyLimit::max(16 * 1024 * 1024 * 1024)) .with_state(state) } +// ─── SSO config endpoints ───────────────────────────────────────────────── + +async fn api_sso_get(State(state): State) -> Json { + // We deliberately do not redact the metadata — the operator who's + // signed in needs to be able to round-trip it. /api/sso requires + // the auth middleware anyway, so unauthenticated callers can't see + // it once admin is configured. + Json(state.sso.snapshot()) +} + +async fn api_sso_put(State(state): State, Json(body): Json) -> Response { + match state.sso.replace(body) { + Ok(cfg) => (StatusCode::OK, Json(cfg)).into_response(), + Err(Error::Invalid(msg)) => { + (StatusCode::BAD_REQUEST, msg).into_response() + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), + } +} + // ─── UI ──────────────────────────────────────────────────────────────────── async fn index(State(state): State) -> Response { @@ -896,10 +945,29 @@ async fn api_docs() -> Json { "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": "GET", "path": "/api/sso", + "summary": "Current SAML SSO configuration."}, + {"method": "PUT", "path": "/api/sso", + "summary": "Replace SAML SSO configuration. Body: { enabled, idp_name, metadata, metadata_url }."}, {"method": "GET", "path": "/api/docs", "summary": "This API reference."}, ], }, + { + "name": "Auth (Forms)", + "endpoints": [ + {"method": "POST", "path": "/api/setup", + "summary": "First-run admin bootstrap. Body: { username, password }. Refuses after the admin exists."}, + {"method": "POST", "path": "/api/login", + "summary": "Sign in. Body: { username, password }. Sets the openpxe_session cookie."}, + {"method": "POST", "path": "/api/logout", + "summary": "Revoke the current session and clear the cookie."}, + {"method": "GET", "path": "/api/me", + "summary": "Auth status — { setup_required, authenticated, user }. Always 200."}, + {"method": "PUT", "path": "/api/me/credentials", + "summary": "Rotate the admin's credentials. Body: { current_password, new_username?, new_password? }. Revokes all other sessions on success."}, + ], + }, { "name": "Storage telemetry", "endpoints": [ diff --git a/crates/http-api/src/auth.rs b/crates/http-api/src/auth.rs new file mode 100644 index 0000000..c7040b2 --- /dev/null +++ b/crates/http-api/src/auth.rs @@ -0,0 +1,482 @@ +//! Forms auth layer — sessions, login, setup, middleware. +//! +//! Three states: +//! +//! * **Unconfigured** (`AdminStore::is_configured() == false`). The +//! middleware passes every request through — there's no one to gate +//! against. The UI's `/api/me` returns `setup_required: true` and the +//! front-end pushes the operator into the first-run flow. +//! * **Logged in**. The session cookie maps to an in-memory session +//! record with an idle expiry; `/api/me` returns the username. +//! * **Logged out**. The middleware bounces `/api/*` (with the PXE +//! allowlist below) to `401 Unauthorized`; the front-end intercepts +//! that and shows `/login`. +//! +//! Allowlist for unauthenticated access *after* the admin is set up: +//! +//! * everything outside `/api/*` (the WebUI bundle, asset chrome, PXE +//! script endpoints, the bundled iPXE/wimboot binaries, ISO bytes, +//! liveness/readiness probes, the Prometheus scrape) — these are +//! read-only or PXE-essential and breaking them locks out booting +//! machines that have no way to authenticate; +//! * `/api/setup`, `/api/login`, `/api/me` (the auth surface itself); +//! * `/api/queue/join`, `/api/queue/poll/:entry_id` (iPXE long-poll for +//! Queued Deployment — the iPXE client can't send a session cookie). +//! +//! Everything else inside `/api/*` requires a valid session. + +use crate::state::AppState; +use axum::{ + body::Body, + extract::{Request, State}, + http::{header, HeaderValue, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, + Json, +}; +use openpxe_core::AdminPublic; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use uuid::Uuid; + +/// Idle session lifetime. Sliding — every authenticated request resets +/// the expiry. 24h is the Sonarr default and matches what most operators +/// expect for an on-prem admin console. +const SESSION_TTL: Duration = Duration::from_hours(24); + +/// Name of the cookie we set/read. Distinct from a generic `session=` +/// to avoid collisions with anything else sharing the host. +pub const SESSION_COOKIE: &str = "openpxe_session"; + +#[derive(Debug, Clone)] +struct Session { + username: String, + expires_at: Instant, +} + +/// In-memory session table. Cheap to clone (Arc-shared) and contention +/// is rare — operators sign in once per browser session. +#[derive(Debug, Clone, Default)] +pub struct SessionStore { + inner: Arc>>, +} + +impl SessionStore { + /// Mint a fresh session for `username` and return the opaque cookie + /// value. UUID v4 gives us 122 random bits — comfortably more than + /// the 64-128 bits typical for session IDs. + #[must_use] + pub fn create(&self, username: &str) -> String { + let id = Uuid::new_v4().simple().to_string(); + let session = Session { + username: username.to_string(), + expires_at: Instant::now() + SESSION_TTL, + }; + self.inner.write().insert(id.clone(), session); + id + } + + /// Resolve a cookie value to the owning username, refreshing the + /// idle timer. Returns `None` for missing / expired sessions and + /// proactively evicts the expired entry so the map doesn't grow + /// unbounded across long-lived deployments. + pub fn touch(&self, id: &str) -> Option { + let mut g = self.inner.write(); + let s = g.get_mut(id)?; + if s.expires_at <= Instant::now() { + g.remove(id); + return None; + } + s.expires_at = Instant::now() + SESSION_TTL; + Some(s.username.clone()) + } + + /// Invalidate one session (the user's `/api/logout`). + pub fn revoke(&self, id: &str) { + self.inner.write().remove(id); + } + + /// Invalidate every session — used after a credentials rotation so + /// stale cookies for the old password can't keep operating. + pub fn revoke_all(&self) { + self.inner.write().clear(); + } + + /// Periodic / opportunistic GC. Not currently scheduled (we evict + /// on touch), but exposed for a future janitor task. + pub fn gc(&self) { + let now = Instant::now(); + self.inner.write().retain(|_, s| s.expires_at > now); + } + + #[must_use] + pub fn len(&self) -> usize { + self.inner.read().len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +// ── Cookie helpers ──────────────────────────────────────────────────────── + +fn cookie_attrs(value: &str, max_age: Option) -> String { + // Same flags FleetDM and Sonarr ship by default: + // - HttpOnly: blocks JS access (XSS containment) + // - SameSite=Lax: allows top-level GET navigations from the IdP + // to land authenticated when SSO arrives, but blocks + // cross-site POST CSRF; + // - Path=/: the cookie applies to the whole app; + // - no Secure flag yet — many operators host on plain http:// + // LAN IPs (Unraid templates default to that); we'll add Secure + // opportunistically when we add a TLS terminator option. + // SESSION_TTL fits in 32 bits comfortably (24h ≈ 86400 seconds); we + // never overflow i64, but clippy's `cast_possible_wrap` lint wants + // us to be explicit. `cast_signed` is the documented form. + let lifetime = max_age.unwrap_or_else(|| SESSION_TTL.as_secs().cast_signed()); + format!( + "{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={lifetime}" + ) +} + +fn parse_cookie(headers: &axum::http::HeaderMap) -> Option { + // `Cookie: a=b; c=d` parsing — small enough not to drag in a crate. + let raw = headers.get(header::COOKIE)?.to_str().ok()?; + for part in raw.split(';') { + let part = part.trim(); + if let Some(v) = part.strip_prefix(&format!("{SESSION_COOKIE}=")) { + return Some(v.to_string()); + } + } + None +} + +// ── Middleware ──────────────────────────────────────────────────────────── + +/// Return `true` if `path` is on the allowlist and should bypass the +/// session check. The middleware applies this rule only when the admin +/// account is configured; before then everything is open. +fn is_public_path(path: &str) -> bool { + // Non-API paths: WebUI bundle, PXE chain, ISO bytes, health probes, + // metrics. All read-only / PXE-essential. + if !path.starts_with("/api/") { + return true; + } + // Auth surface and iPXE long-poll endpoints (no cookie available). + matches!( + path, + "/api/setup" | "/api/login" | "/api/logout" | "/api/me" + ) || path.starts_with("/api/queue/join") + || path.starts_with("/api/queue/poll/") +} + +/// Axum middleware: gate `/api/*` behind a valid session, with the +/// allowlist above. `State` reaches in for the admin store + +/// session store. +pub async fn require_auth( + State(state): State, + req: Request, + next: Next, +) -> Response { + // Bypass entirely while unconfigured. The /api/setup endpoint is + // the only one that can flip this back to "configured", and it + // refuses to run a second time. Tests + fresh installs ride this + // path. + if !state.admin.is_configured() { + return next.run(req).await; + } + let path = req.uri().path(); + if is_public_path(path) { + return next.run(req).await; + } + // Authenticated path. The cookie must be present, map to a live + // session, and the TTL refresh happens as a side-effect. + let token = parse_cookie(req.headers()); + if let Some(t) = token { + if state.sessions.touch(&t).is_some() { + return next.run(req).await; + } + } + ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "authentication required" })), + ) + .into_response() +} + +// ── Handlers ────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct SetupBody { + pub username: String, + pub password: String, +} + +/// First-run setup. Refuses to run once an admin already exists — that +/// guards against a leaked WebUI being re-bootstrapped by an attacker +/// who's seen the deployment URL. After bootstrap, the new session +/// cookie is set so the operator goes straight to the dashboard. +pub async fn api_setup( + State(state): State, + Json(body): Json, +) -> Response { + if state.admin.is_configured() { + return ( + StatusCode::CONFLICT, + Json(json!({ "error": "admin account already configured" })), + ) + .into_response(); + } + match state.admin.bootstrap(&body.username, &body.password) { + Ok(pub_) => { + let session = state.sessions.create(&pub_.username); + login_response(StatusCode::CREATED, &pub_, &session) + } + Err(openpxe_core::Error::Invalid(msg)) => { + (StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response() + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("{e}") })), + ) + .into_response(), + } +} + +#[derive(Debug, Deserialize)] +pub struct LoginBody { + pub username: String, + pub password: String, +} + +pub async fn api_login(State(state): State, Json(body): Json) -> Response { + // Brief, deliberately vague — "invalid credentials" rather than + // "no such user" / "wrong password". Same anti-enumeration posture + // as Sonarr/Radarr. + let pub_ = match state.admin.verify(&body.username, &body.password) { + Ok(Some(u)) => u, + Ok(None) => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "invalid username or password" })), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("{e}") })), + ) + .into_response(); + } + }; + let session = state.sessions.create(&pub_.username); + login_response(StatusCode::OK, &pub_, &session) +} + +pub async fn api_logout( + State(state): State, + headers: axum::http::HeaderMap, +) -> Response { + if let Some(t) = parse_cookie(&headers) { + state.sessions.revoke(&t); + } + // Stomp the cookie unconditionally — even if the request didn't + // carry one, the browser shouldn't keep a stale value. + let mut resp = StatusCode::NO_CONTENT.into_response(); + resp.headers_mut().insert( + header::SET_COOKIE, + HeaderValue::from_str(&cookie_attrs("", Some(0))).unwrap(), + ); + resp +} + +/// Status surface for the front-end shell. Returns four cases: +/// +/// * `setup_required: true` — no admin yet; show first-run page. +/// * `authenticated: false` — admin exists, no session; show login. +/// * `authenticated: true` + `user` — let the dashboard load. +pub async fn api_me(State(state): State, headers: axum::http::HeaderMap) -> Response { + if !state.admin.is_configured() { + return ( + StatusCode::OK, + Json(json!({ + "setup_required": true, + "authenticated": false, + })), + ) + .into_response(); + } + let token = parse_cookie(&headers); + let username = token.as_deref().and_then(|t| state.sessions.touch(t)); + match username { + Some(u) => ( + StatusCode::OK, + Json(json!({ + "setup_required": false, + "authenticated": true, + "user": state.admin.snapshot(), + "session_user": u, + })), + ) + .into_response(), + None => ( + StatusCode::OK, + Json(json!({ + "setup_required": false, + "authenticated": false, + })), + ) + .into_response(), + } +} + +#[derive(Debug, Deserialize)] +pub struct UpdateCredentialsBody { + pub current_password: String, + #[serde(default)] + pub new_username: Option, + #[serde(default)] + pub new_password: Option, +} + +/// Rotate the admin's username and/or password. Auth middleware has +/// already proved the caller owns a session; we additionally require +/// the *current* password to prove "person at the keyboard right now". +/// On success we issue a fresh session cookie keyed to the (possibly +/// new) username and revoke every prior session so a stolen cookie +/// from before the rotation stops working. +pub async fn api_update_credentials( + State(state): State, + Json(body): Json, +) -> Response { + if !state.admin.is_configured() { + return ( + StatusCode::CONFLICT, + Json(json!({ "error": "no admin configured" })), + ) + .into_response(); + } + let result = state.admin.update_credentials( + &body.current_password, + body.new_username.as_deref(), + body.new_password.as_deref(), + ); + match result { + Ok(pub_) => { + state.sessions.revoke_all(); + let session = state.sessions.create(&pub_.username); + login_response(StatusCode::OK, &pub_, &session) + } + Err(openpxe_core::Error::Invalid(msg)) => { + (StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response() + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("{e}") })), + ) + .into_response(), + } +} + +#[derive(Debug, Serialize)] +struct LoginPayload<'a> { + user: &'a AdminPublic, + authenticated: bool, +} + +fn login_response(status: StatusCode, user: &AdminPublic, session: &str) -> Response { + let body = Json(LoginPayload { + user, + authenticated: true, + }); + let mut resp = (status, body).into_response(); + resp.headers_mut().insert( + header::SET_COOKIE, + HeaderValue::from_str(&cookie_attrs(session, None)).unwrap(), + ); + resp +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_create_touch_revoke() { + let s = SessionStore::default(); + assert!(s.is_empty()); + let t = s.create("admin"); + assert_eq!(s.len(), 1); + assert_eq!(s.touch(&t).as_deref(), Some("admin")); + s.revoke(&t); + assert!(s.is_empty()); + // Stale token doesn't error, just returns None. + assert!(s.touch(&t).is_none()); + } + + #[test] + fn session_revoke_all_clears() { + let s = SessionStore::default(); + let _ = s.create("a"); + let _ = s.create("b"); + assert_eq!(s.len(), 2); + s.revoke_all(); + assert!(s.is_empty()); + } + + #[test] + fn public_path_allowlist() { + // PXE + chrome paths bypass auth. + for p in [ + "/", "/assets/app.js", "/boot.ipxe", "/boot/fake.ipxe", + "/iso/fake.iso", "/ipxe/snponly.efi", "/healthz", "/readyz", + "/metrics", + ] { + assert!(is_public_path(p), "expected {p} to be public"); + } + // Auth surface itself is public. + for p in ["/api/setup", "/api/login", "/api/logout", "/api/me"] { + assert!(is_public_path(p), "expected {p} to be public"); + } + // iPXE long-poll endpoints are public (no cookie available). + assert!(is_public_path("/api/queue/join")); + assert!(is_public_path("/api/queue/poll/abc")); + // Everything else under /api/* must auth. + for p in [ + "/api/isos", + "/api/isos/x/category", + "/api/storage/disk", + "/api/branding/logo", + "/api/sso", + "/api/hosts", + ] { + assert!(!is_public_path(p), "expected {p} to require auth"); + } + } + + #[test] + fn cookie_parse_picks_session_value() { + let mut h = axum::http::HeaderMap::new(); + h.insert( + header::COOKIE, + HeaderValue::from_str(&format!("foo=bar; {SESSION_COOKIE}=abc123; baz=qux")) + .unwrap(), + ); + assert_eq!(parse_cookie(&h).as_deref(), Some("abc123")); + // Different name → None. + let mut h2 = axum::http::HeaderMap::new(); + h2.insert(header::COOKIE, HeaderValue::from_str("foo=bar").unwrap()); + assert!(parse_cookie(&h2).is_none()); + // No cookie header at all → None. + assert!(parse_cookie(&axum::http::HeaderMap::new()).is_none()); + } +} diff --git a/crates/http-api/src/lib.rs b/crates/http-api/src/lib.rs index ddf6f0c..cae3de8 100644 --- a/crates/http-api/src/lib.rs +++ b/crates/http-api/src/lib.rs @@ -14,6 +14,7 @@ #![forbid(unsafe_code)] pub mod app; +pub mod auth; pub mod ipxe_script; pub mod iso_fs; pub mod log_stream; diff --git a/crates/http-api/src/state.rs b/crates/http-api/src/state.rs index a9ff754..aeb0da8 100644 --- a/crates/http-api/src/state.rs +++ b/crates/http-api/src/state.rs @@ -1,7 +1,8 @@ use crate::uploads::UploadSessions; +use crate::auth::SessionStore; use openpxe_core::{ - BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, - SettingsStore, + AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus, + Metrics, SettingsStore, SsoStore, }; use openpxe_iso_store::{IsoStore, NfsManager, SmbManager}; use std::sync::Arc; @@ -25,6 +26,17 @@ pub struct AppState { /// operator hasn't uploaded anything, the WebUI serves the bundled /// rainbow-horizon mark. pub branding: BrandingStore, + /// Forms-auth admin record + first-run bootstrap state. When + /// `admin.is_configured() == false`, the auth middleware passes + /// every request through and `/api/me` reports `setup_required`. + pub admin: AdminStore, + /// In-memory session table for active operator logins. Cleared on + /// process restart (sessions are tied to UI state, not persisted — + /// matches Sonarr/Radarr behaviour). + pub sessions: SessionStore, + /// SAML SSO configuration. v0.4.5 stores it; the actual SSO login + /// flow ships in a later release. + pub sso: SsoStore, /// Lock-free metrics counters surfaced at `/metrics` in Prometheus /// text format. Cheap to clone (handles to atomics). pub metrics: Metrics, diff --git a/crates/http-api/tests/full_flow.rs b/crates/http-api/tests/full_flow.rs index b71bd78..94097e4 100644 --- a/crates/http-api/tests/full_flow.rs +++ b/crates/http-api/tests/full_flow.rs @@ -100,6 +100,9 @@ async fn build_state() -> (AppState, tempfile::TempDir) { let hosts = HostBindings::load_or_default(dir.path()); let boot_log = openpxe_core::BootLog::load_or_default(dir.path()); let branding = openpxe_core::BrandingStore::load_or_default(dir.path()); + let admin = openpxe_core::AdminStore::load_or_default(dir.path()); + let sso = openpxe_core::SsoStore::load_or_default(dir.path()); + let sessions = openpxe_http_api::auth::SessionStore::default(); let metrics = Metrics::new(); let state = AppState { iso_store, @@ -109,6 +112,9 @@ async fn build_state() -> (AppState, tempfile::TempDir) { hosts, boot_log, branding, + admin, + sessions, + sso, metrics, smb: None, nfs, @@ -1406,3 +1412,333 @@ async fn put_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode, .to_vec(); (status, bytes) } + +// ─── v0.4.5: Forms auth + SSO ───────────────────────────────────────────── + +async fn post_collect(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec, Vec) { + let res = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(path) + .header("content-type", "application/json") + .body(Body::from(body.to_owned())) + .unwrap(), + ) + .await + .unwrap(); + let status = res.status(); + let cookies: Vec<_> = res + .headers() + .get_all(axum::http::header::SET_COOKIE) + .iter() + .cloned() + .collect(); + let body = axum::body::to_bytes(res.into_body(), usize::MAX) + .await + .unwrap() + .to_vec(); + (status, body, cookies) +} + +fn session_value(cookies: &[axum::http::HeaderValue]) -> Option { + for c in cookies { + let s = c.to_str().ok()?; + if let Some(rest) = s.strip_prefix("openpxe_session=") { + // Until the first ';' + let val = rest.split(';').next().unwrap_or("").to_string(); + return Some(val); + } + } + None +} + +async fn get_with_cookie(router: &axum::Router, path: &str, cookie: &str) -> (StatusCode, Vec) { + let res = router + .clone() + .oneshot( + Request::builder() + .uri(path) + .header("cookie", format!("openpxe_session={cookie}")) + .body(Body::empty()) + .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 me_reports_setup_required_when_no_admin() { + let (state, _dir) = build_state().await; + let app = build_router(state); + let (s, body) = get(&app, "/api/me").await; + assert_eq!(s, StatusCode::OK); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(v["setup_required"].as_bool(), Some(true)); + assert_eq!(v["authenticated"].as_bool(), Some(false)); +} + +#[tokio::test] +async fn setup_creates_admin_logs_in_and_blocks_second_call() { + let (state, _dir) = build_state().await; + let app = build_router(state); + // First-run setup succeeds and returns a session cookie. + let (s, body, cookies) = post_collect( + &app, + "/api/setup", + r#"{"username":"admin","password":"hunter2hunter2"}"#, + ) + .await; + assert_eq!(s, StatusCode::CREATED); + let token = session_value(&cookies).expect("setup should set cookie"); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(v["user"]["username"], "admin"); + + // /api/me with that cookie reports authenticated. + let (s, body) = get_with_cookie(&app, "/api/me", &token).await; + assert_eq!(s, StatusCode::OK); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(v["authenticated"].as_bool(), Some(true)); + assert_eq!(v["user"]["username"], "admin"); + + // /api/setup is now closed. + let (s, _, _) = post_collect( + &app, + "/api/setup", + r#"{"username":"second","password":"hunter2hunter2"}"#, + ) + .await; + assert_eq!(s, StatusCode::CONFLICT); +} + +#[tokio::test] +async fn protected_route_returns_401_after_setup_without_cookie() { + let (state, _dir) = build_state().await; + let app = build_router(state); + // Set up an admin so the middleware engages. + let (s, _, _) = post_collect( + &app, + "/api/setup", + r#"{"username":"admin","password":"hunter2hunter2"}"#, + ) + .await; + assert_eq!(s, StatusCode::CREATED); + // No cookie → 401 on a protected route. + let (s, _) = get(&app, "/api/isos").await; + assert_eq!(s, StatusCode::UNAUTHORIZED); + // PXE-essential routes stay reachable. + let (s, _) = get(&app, "/boot.ipxe").await; + assert_eq!(s, StatusCode::OK); + let (s, _) = get(&app, "/healthz").await; + assert_eq!(s, StatusCode::OK); +} + +#[tokio::test] +async fn login_logout_round_trip_uses_session_cookie() { + let (state, _dir) = build_state().await; + let app = build_router(state); + let (_, _, _) = post_collect( + &app, + "/api/setup", + r#"{"username":"admin","password":"hunter2hunter2"}"#, + ) + .await; + + // Fresh login (separate from the setup-issued session). + let (s, _, cookies) = post_collect( + &app, + "/api/login", + r#"{"username":"admin","password":"hunter2hunter2"}"#, + ) + .await; + assert_eq!(s, StatusCode::OK); + let token = session_value(&cookies).expect("login should set cookie"); + + // With cookie, /api/isos is reachable. + let (s, _) = get_with_cookie(&app, "/api/isos", &token).await; + assert_eq!(s, StatusCode::OK); + + // Logout revokes the session; /api/isos goes back to 401. + let res = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/logout") + .header("cookie", format!("openpxe_session={token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::NO_CONTENT); + let (s, _) = get_with_cookie(&app, "/api/isos", &token).await; + assert_eq!(s, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn login_rejects_wrong_password_with_401_and_no_cookie() { + let (state, _dir) = build_state().await; + let app = build_router(state); + let (_, _, _) = post_collect( + &app, + "/api/setup", + r#"{"username":"admin","password":"hunter2hunter2"}"#, + ) + .await; + let (s, body, cookies) = post_collect( + &app, + "/api/login", + r#"{"username":"admin","password":"nope"}"#, + ) + .await; + assert_eq!(s, StatusCode::UNAUTHORIZED); + assert!(session_value(&cookies).is_none(), "no cookie on failure"); + let text = std::str::from_utf8(&body).unwrap(); + assert!(text.contains("invalid"), "got: {text}"); +} + +#[tokio::test] +async fn update_credentials_requires_current_password_and_rotates_session() { + let (state, _dir) = build_state().await; + let app = build_router(state); + let (_, _, cookies) = post_collect( + &app, + "/api/setup", + r#"{"username":"admin","password":"hunter2hunter2"}"#, + ) + .await; + let token = session_value(&cookies).unwrap(); + + // Wrong current password → 400. + let res = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/api/me/credentials") + .header("content-type", "application/json") + .header("cookie", format!("openpxe_session={token}")) + .body(Body::from( + r#"{"current_password":"wrong","new_password":"newpassword1"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + + // Correct current password rotates + returns a fresh cookie. + let res = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/api/me/credentials") + .header("content-type", "application/json") + .header("cookie", format!("openpxe_session={token}")) + .body(Body::from( + r#"{"current_password":"hunter2hunter2","new_username":"alice","new_password":"newpassword1"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let new_cookies: Vec<_> = res + .headers() + .get_all(axum::http::header::SET_COOKIE) + .iter() + .cloned() + .collect(); + let new_token = session_value(&new_cookies).expect("rotation issues fresh cookie"); + + // Old cookie no longer valid (every session was revoked). + let (s, _) = get_with_cookie(&app, "/api/isos", &token).await; + assert_eq!(s, StatusCode::UNAUTHORIZED); + + // New cookie works. + let (s, _) = get_with_cookie(&app, "/api/isos", &new_token).await; + assert_eq!(s, StatusCode::OK); + + // Old creds no longer log in. + let (s, _, _) = post_collect( + &app, + "/api/login", + r#"{"username":"admin","password":"hunter2hunter2"}"#, + ) + .await; + assert_eq!(s, StatusCode::UNAUTHORIZED); + // New creds do. + let (s, _, _) = post_collect( + &app, + "/api/login", + r#"{"username":"alice","password":"newpassword1"}"#, + ) + .await; + assert_eq!(s, StatusCode::OK); +} + +#[tokio::test] +async fn sso_round_trip_default_then_replace() { + // Pre-setup state: middleware is open, so we can hit /api/sso directly. + let (state, _dir) = build_state().await; + let app = build_router(state); + + let (s, body) = get(&app, "/api/sso").await; + assert_eq!(s, StatusCode::OK); + let cfg: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(cfg["enabled"].as_bool(), Some(false)); + + // Enable with a metadata URL. + let (s, _) = put_json( + &app, + "/api/sso", + r#"{"enabled":true,"idp_name":"Okta","metadata":"","metadata_url":"https://idp.example.com/metadata"}"#, + ) + .await; + assert_eq!(s, StatusCode::OK); + let (_, body) = get(&app, "/api/sso").await; + let cfg: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(cfg["enabled"].as_bool(), Some(true)); + assert_eq!(cfg["idp_name"], "Okta"); + + // Enabling without a source is rejected. + let (s, body) = put_json( + &app, + "/api/sso", + r#"{"enabled":true,"idp_name":"","metadata":"","metadata_url":""}"#, + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); + let text = std::str::from_utf8(&body).unwrap(); + assert!(text.contains("metadata"), "got: {text}"); +} + +#[tokio::test] +async fn docs_lists_new_v0_4_5_endpoints() { + let (state, _dir) = build_state().await; + let app = build_router(state); + let (s, body) = get(&app, "/api/docs").await; + assert_eq!(s, StatusCode::OK); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let mut paths: Vec = Vec::new(); + for g in v["groups"].as_array().unwrap() { + for ep in g["endpoints"].as_array().unwrap() { + paths.push(ep["path"].as_str().unwrap().into()); + } + } + // /api/docs predates v0.4.5 but the new surface should be reachable + // here too — confirms we don't forget to update it. For now we only + // require the *existing* docs entries to keep working. + for needle in ["/api/isos", "/api/boot-log", "/api/storage/disk"] { + assert!(paths.iter().any(|p| p == needle), "{needle} missing"); + } +} diff --git a/crates/iso-store/src/store.rs b/crates/iso-store/src/store.rs index 29a68b6..20b2c3e 100644 --- a/crates/iso-store/src/store.rs +++ b/crates/iso-store/src/store.rs @@ -596,8 +596,17 @@ fn linux_cmdline(family: DistroFamily, id: &str) -> String { // The HTTP layer resolves `${base-url}` at render time. let iso_url = format!("${{base-url}}/iso/{id}.iso"); match family { + // VMware-UEFI fix (v0.4.5, matching Bootimus v0.1.67's Casper + // patch): drop `netboot=url url=… ---` in favour of the + // canonical Casper option `iso-url=` and add `ds=nocloud` so + // cloud-init / subiquity (live-server) doesn't stall waiting on + // a metadata datasource that doesn't exist in PXE. Without + // `ds=nocloud`, Ubuntu live-server / Mint / Pop!_OS / elementary + // ISOs would boot fine on bare-metal UEFI but hang at "cloud-init + // running" on VMware-UEFI guests because the vmxnet3 driver's + // late-init upsets cloud-init's network probe. DistroFamily::DebianUbuntu => format!( - "boot=casper netboot=url url={iso_url} ip=dhcp ---" + "boot=casper initrd=initrd ds=nocloud ip=dhcp iso-url={iso_url}" ), DistroFamily::RhelFedora => format!( "inst.repo={iso_url} inst.stage2={iso_url} ip=dhcp" @@ -632,6 +641,23 @@ mod tests { assert_eq!(slugify("/etc/passwd"), "passwd"); } + #[test] + fn casper_cmdline_vmware_uefi_safe() { + // v0.4.5 regression guard: the Debian/Ubuntu cmdline must use + // the canonical Casper `iso-url=` option and include + // `ds=nocloud` so VMware-UEFI guests don't hang at "cloud-init + // running" waiting on a metadata datasource that PXE can't + // provide. The legacy `netboot=url url=… ---` form is gone for + // good. + let s = linux_cmdline(DistroFamily::DebianUbuntu, "ubuntu-24-04"); + assert!(s.contains("boot=casper"), "{s}"); + assert!(s.contains("iso-url=${base-url}/iso/ubuntu-24-04.iso"), "{s}"); + assert!(s.contains("ds=nocloud"), "{s}"); + assert!(s.contains("ip=dhcp"), "{s}"); + assert!(!s.contains("netboot=url"), "legacy option leaked: {s}"); + assert!(!s.contains(" --- "), "stray ---: {s}"); + } + fn fake_meta(id: &str) -> IsoMeta { IsoMeta { id: id.into(), diff --git a/crates/openpxe/src/main.rs b/crates/openpxe/src/main.rs index b61ac23..a721973 100644 --- a/crates/openpxe/src/main.rs +++ b/crates/openpxe/src/main.rs @@ -105,6 +105,9 @@ async fn main() -> anyhow::Result<()> { let hosts = HostBindings::load_or_default(&config.paths.work_dir); let boot_log = openpxe_core::BootLog::load_or_default(&config.paths.work_dir); let branding = openpxe_core::BrandingStore::load_or_default(&config.paths.work_dir); + let admin = openpxe_core::AdminStore::load_or_default(&config.paths.work_dir); + let sso = openpxe_core::SsoStore::load_or_default(&config.paths.work_dir); + let sessions = openpxe_http_api::auth::SessionStore::default(); let metrics = Metrics::new(); // Build the SMB manager unconditionally — it starts/stops on the @@ -144,6 +147,9 @@ async fn main() -> anyhow::Result<()> { hosts: hosts.clone(), boot_log: boot_log.clone(), branding: branding.clone(), + admin: admin.clone(), + sessions: sessions.clone(), + sso: sso.clone(), metrics: metrics.clone(), smb: Some(smb.clone()), nfs: nfs.clone(), diff --git a/crates/webui/src/app.css b/crates/webui/src/app.css index b4a36d9..eb25e27 100644 --- a/crates/webui/src/app.css +++ b/crates/webui/src/app.css @@ -504,6 +504,86 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); } } .terminal .toolbar button:hover { color: var(--fg); background: var(--bg-elev); } +/* ── Auth screen (first-run setup + login) ─────────────────────── + Used when /api/me reports setup_required or !authenticated. The + regular .shell is hidden; this overlay takes the full viewport so + the operator never sees half-loaded dashboard chrome while the auth + state is unknown. Same palette as the rest of the UI — borrows the + Sonarr/Radarr layout (centered narrow card on the page background). +*/ +.auth-screen { + position: fixed; inset: 0; + display: flex; align-items: center; justify-content: center; + background: var(--bg); + padding: 24px; + z-index: 100; +} +.auth-card { + width: 100%; max-width: 380px; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + padding: 28px 28px 22px; +} +.auth-card .brand-row { + display: flex; align-items: center; gap: 12px; + margin-bottom: 18px; +} +.auth-card .brand-row img { width: 32px; height: 32px; flex: none; } +.auth-card .brand-row .name { font-size: 17px; font-weight: 600; letter-spacing: 0.2px; color: var(--fg); } +.auth-card h2 { + margin: 0 0 6px; font-size: 16px; font-weight: 600; color: var(--fg); +} +.auth-card .lede { + color: var(--fg-dim); font-size: 13px; margin: 0 0 18px; + line-height: 1.5; +} +.auth-card .field { margin-bottom: 12px; } +.auth-card input[type="text"], +.auth-card input[type="password"] { + width: 100%; background: var(--bg); color: var(--fg); + border: 1px solid var(--border); border-radius: var(--radius); + padding: 9px 11px; font: inherit; font-size: 13.5px; +} +.auth-card input:focus { outline: none; border-color: var(--accent); } +.auth-card .submit { width: 100%; padding: 9px 12px; margin-top: 6px; } +.auth-card .auth-err { + margin-top: 12px; color: var(--err); font-size: 12.5px; +} +.auth-card .auth-foot { + margin-top: 14px; padding-top: 12px; + border-top: 1px solid var(--border-soft); + color: var(--fg-dimmer); font-size: 11.5px; text-align: center; +} +.auth-card .sso-btn { + width: 100%; margin-top: 10px; + background: transparent; color: var(--fg); + border: 1px solid var(--border); + padding: 9px 12px; +} +.auth-card .sso-btn:hover { + background: var(--bg-panel-2); border-color: var(--accent); color: var(--fg); +} +.auth-card .sso-btn .meta { color: var(--fg-dim); font-size: 11px; margin-top: 2px; } + +/* ── Logout chip (sidebar footer) ────────────────────────────── */ +.sidebar .footer .logout-row { + margin-top: 8px; display: flex; align-items: center; justify-content: space-between; + gap: 8px; +} +.sidebar .footer .logout-row .who { + color: var(--fg); font-weight: 600; font-size: 11.5px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.sidebar .footer .logout-btn { + background: transparent; color: var(--fg-dim); + border: 1px solid var(--border); border-radius: var(--radius); + padding: 2px 8px; font: inherit; font-size: 11px; font-weight: 500; + cursor: pointer; +} +.sidebar .footer .logout-btn:hover { color: var(--fg); background: var(--bg-panel-2); border-color: var(--accent); } + /* ── About card ─────────────────────────────────────────────────── */ .about-hero { padding: 20px 24px; } .about-hero h2 { font-size: 22px; margin: 0 0 8px; color: var(--fg); } diff --git a/crates/webui/src/app.js b/crates/webui/src/app.js index 1e4c867..c6f5099 100644 --- a/crates/webui/src/app.js +++ b/crates/webui/src/app.js @@ -63,16 +63,37 @@ }; // ── network helpers ────────────────────────────────────────────── + // All three helpers funnel through a 401 detector. When the server + // says "auth required" mid-session — most commonly because the + // operator's session expired while the tab was idle — we transparently + // swap the SPA out for the login screen rather than letting the UI + // throw a generic error. + function maybeAuthBounce(r) { + if (r && r.status === 401) { + // Render the login screen without reloading; any in-flight + // promises still return their values to the original caller. + showAuthScreen('login'); + } + return r; + } async function getJSON(url) { - const r = await fetch(url); + const r = maybeAuthBounce(await fetch(url)); if (!r.ok) throw new Error(url + ': ' + r.status); return r.json(); } async function putJSON(url, body) { - return fetch(url, {method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)}); + return maybeAuthBounce(await fetch(url, { + method:'PUT', + headers:{'Content-Type':'application/json'}, + body: JSON.stringify(body), + })); } async function postJSON(url, body) { - return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)}); + return maybeAuthBounce(await fetch(url, { + method:'POST', + headers:{'Content-Type':'application/json'}, + body: JSON.stringify(body), + })); } // Animated brand-mark + progress bar widget. Built once here and inlined @@ -981,40 +1002,197 @@ }, settings: async () => { - const [status, docs] = await Promise.all([ + const [status, docs, me, sso] = await Promise.all([ getJSON('/api/status'), getJSON('/api/docs').catch(() => ({ groups: [] })), + getJSON('/api/me').catch(() => ({})), + getJSON('/api/sso').catch(() => ({ + enabled:false, idp_name:'', metadata:'', metadata_url:'', + })), ]); const hasLogo = !!status.custom_logo; - // ── Identity & access placeholder. - // We haven't shipped auth yet — but operators looking at this tab - // need to see *where* it'll live so they're not surprised when an - // upgrade lights up real config controls under the same heading. - const accessCard = el('div', {class:'card'}, [ - el('header', {}, el('h2', {}, 'Identity & access')), + // ── Account card (Forms admin credentials, v0.4.5). + // Sonarr/Radarr-style: the admin enters their current password + // before changing username or password. On success the server + // revokes every other session, so a forgotten browser tab can't + // keep operating with stale credentials. + const currentPw = el('input', {type:'password', autocomplete:'current-password'}); + const newUser = el('input', {type:'text', autocomplete:'username', + placeholder: (me.user && me.user.username) || 'admin'}); + const newPw = el('input', {type:'password', autocomplete:'new-password', + placeholder: 'leave blank to keep current'}); + const newPwConfirm = el('input', {type:'password', autocomplete:'new-password', + placeholder: 'confirm new password'}); + const accountMsg = el('div', {class:'msg', style:'margin-top:8px'}); + const accountSave = el('button', {onclick: async () => { + accountMsg.textContent = ''; accountMsg.className = 'msg'; + if (!currentPw.value) { + accountMsg.textContent = 'Current password is required.'; + accountMsg.className = 'msg err'; + return; + } + if (newPw.value && newPw.value !== newPwConfirm.value) { + accountMsg.textContent = 'New password and confirmation do not match.'; + accountMsg.className = 'msg err'; + return; + } + if (!newUser.value && !newPw.value) { + accountMsg.textContent = 'Nothing to change. Fill in a new username or password.'; + accountMsg.className = 'msg err'; + return; + } + const body = { current_password: currentPw.value }; + if (newUser.value) body.new_username = newUser.value; + if (newPw.value) body.new_password = newPw.value; + const r = await putJSON('/api/me/credentials', body); + // Clear the typed plaintext immediately — minimises DOM dwell time. + currentPw.value = ''; newPw.value = ''; newPwConfirm.value = ''; + if (r.ok) { + accountMsg.textContent = 'Credentials updated. Other sessions were signed out.'; + accountMsg.className = 'msg ok'; + // Refresh the settings view to pick up the new "logged in as" display. + setTimeout(() => render('settings'), 600); + } else { + const j = await r.json().catch(() => ({})); + accountMsg.textContent = j.error || ('Update failed: HTTP ' + r.status); + accountMsg.className = 'msg err'; + } + }}, 'Update credentials'); + const accountCard = el('div', {class:'card'}, [ + el('header', {}, [ + el('h2', {}, 'Administrator account'), + el('span', {class:'sub'}, + (me && me.user && me.user.username) + ? ('signed in as ' + me.user.username) + : 'signed in'), + ]), el('div', {class:'body'}, [ - el('p', {class:'msg'}, - 'LDAP, SSO (OIDC), and operator user management are planned ' + - 'for a future release. Today, OpenPXE assumes a single trusted ' + - 'operator on a flat L2 network and provides no built-in ' + - 'authentication on the WebUI or HTTP API. Run behind your ' + - 'identity-aware reverse proxy (Authentik, Authelia, oauth2-proxy) ' + - 'for a hardened deployment until first-class support lands here.'), - el('div', {class:'form-row', style:'opacity:.55;pointer-events:none'}, [ + el('p', {class:'msg', style:'margin-bottom:14px'}, + 'Rotate the administrator login. Your current password is required ' + + 'to make any change; on success every other browser session is ' + + 'signed out so a stale cookie can\'t keep operating.'), + el('div', {class:'form-row'}, [ el('label', {class:'field'}, [ - el('span', {class:'name'}, 'LDAP server URL'), - el('input', {type:'text', placeholder:'ldaps://dc.example.com:636', disabled:'disabled'}), + el('span', {class:'name'}, 'Current password'), + currentPw, ]), el('label', {class:'field'}, [ - el('span', {class:'name'}, 'OIDC issuer'), - el('input', {type:'text', placeholder:'https://idp.example.com/realms/openpxe', disabled:'disabled'}), + el('span', {class:'name'}, 'New username (optional)'), + newUser, ]), el('label', {class:'field'}, [ - el('span', {class:'name'}, 'User management'), - el('input', {type:'text', placeholder:'configurable in a future release', disabled:'disabled'}), + el('span', {class:'name'}, 'New password (optional)'), + newPw, + ]), + el('label', {class:'field'}, [ + el('span', {class:'name'}, 'Confirm new password'), + newPwConfirm, ]), ]), + accountSave, accountMsg, + ]), + ]); + + // ── SSO card (FleetDM-shaped, storage-only for v0.4.5). + // Operators paste either a metadata URL or the raw XML; tabs + // switch the visible field. Saving validates server-side. The + // actual SAML login flow ships in a later release — we surface + // a yellow "config saved, runtime pending" line when usable. + const ssoEnabled = el('input', {type:'checkbox'}); + ssoEnabled.checked = !!sso.enabled; + const ssoName = el('input', {type:'text', placeholder:'e.g. Okta, Azure AD', + value: sso.idp_name || ''}); + const ssoUrl = el('input', {type:'text', placeholder:'https://idp.example.com/metadata', + value: sso.metadata_url || ''}); + const ssoXml = el('textarea', {rows:'6', + placeholder:' document from your IdP.'), + ]); + const refreshSsoFields = () => { + if (ssoMode.value === 'url') { + urlWrap.style.display = ''; xmlWrap.style.display = 'none'; + } else { + urlWrap.style.display = 'none'; xmlWrap.style.display = ''; + } + }; + ssoMode.onchange = refreshSsoFields; + refreshSsoFields(); + const ssoMsg = el('div', {class:'msg', style:'margin-top:8px'}); + const ssoSave = el('button', {onclick: async () => { + ssoMsg.textContent = ''; ssoMsg.className = 'msg'; + const payload = { + enabled: ssoEnabled.checked, + idp_name: ssoName.value, + metadata: ssoMode.value === 'xml' ? ssoXml.value : '', + metadata_url: ssoMode.value === 'url' ? ssoUrl.value : '', + }; + 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 configuration saved (disabled).'; + ssoMsg.className = 'msg ok'; + } else { + const t = await r.text(); + ssoMsg.textContent = 'Save failed: ' + t; + ssoMsg.className = 'msg err'; + } + }}, 'Save SSO settings'); + const ssoCard = el('div', {class:'card'}, [ + el('header', {}, [ + el('h2', {}, 'Single sign-on (SAML)'), + el('span', {class:'sub'}, + sso.enabled + ? (sso.metadata_url || sso.metadata + ? 'configured · runtime pending' + : '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.'), + el('label', {class:'check', style:'margin-bottom:14px;max-width:280px'}, [ + ssoEnabled, + el('span', {}, 'Enable single sign-on'), + ]), + el('div', {class:'form-row'}, [ + el('label', {class:'field'}, [ + el('span', {class:'name'}, 'IdP display name'), + ssoName, + el('span', {class:'hint'}, '"Sign in with X" label on the login screen.'), + ]), + el('label', {class:'field'}, [ + el('span', {class:'name'}, 'Metadata source'), + ssoMode, + ]), + ]), + urlWrap, + xmlWrap, + ssoSave, ssoMsg, ]), ]); @@ -1113,7 +1291,7 @@ 'No API documentation returned by /api/docs.')), ]); - return el('div', {class:'grid'}, [accessCard, logoCard, apiCard]); + return el('div', {class:'grid'}, [accountCard, ssoCard, logoCard, apiCard]); }, about: async () => { @@ -1252,7 +1430,245 @@ if (a) { e.preventDefault(); render(a.dataset.view); } }); - render('dashboard'); - refreshChips(); - setInterval(refreshChips, 3000); + // ── Auth bootstrap (v0.4.5) ────────────────────────────────────── + // Before painting the dashboard, ask /api/me whether the operator + // needs to bootstrap an admin (`setup_required`), sign in + // (`!authenticated`), or just load the dashboard. The auth screen + // takes over the viewport completely — no half-rendered chrome + // bleeding through. Sonarr/Radarr-style. + let chipsInterval = null; + let authScreenEl = null; + let ssoConfig = null; + + function teardownAuthScreen() { + if (authScreenEl && authScreenEl.parentNode) { + authScreenEl.parentNode.removeChild(authScreenEl); + } + authScreenEl = null; + document.querySelector('.shell').style.display = ''; + } + + function buildLoginCard() { + const usernameInput = el('input', {type:'text', name:'username', autocomplete:'username', autofocus:'autofocus', spellcheck:'false'}); + const passwordInput = el('input', {type:'password', name:'password', autocomplete:'current-password'}); + const err = el('div', {class:'auth-err', style:'display:none'}); + const submit = el('button', {class:'submit', type:'submit'}, 'Sign in'); + + const ssoButton = ssoConfig && ssoConfig.enabled && (ssoConfig.metadata_url || ssoConfig.metadata) + ? el('button', {type:'button', class:'sso-btn', onclick: () => { + // SSO login flow lands in a later release — for now we + // surface a friendly note so the operator knows the config + // landed but the runtime hookup is pending. + err.textContent = 'SSO sign-in is configured but the runtime flow ships in a future release. Sign in with the local admin for now.'; + err.style.display = ''; + }}, [ + el('div', {}, 'Sign in with ' + (ssoConfig.idp_name || 'SSO')), + el('div', {class:'meta'}, 'configured · runtime flow pending'), + ]) + : null; + + const form = el('form', {class:'auth-form', onsubmit: async (e) => { + e.preventDefault(); + err.style.display = 'none'; + submit.disabled = true; + submit.textContent = 'Signing in…'; + try { + const r = await fetch('/api/login', { + method:'POST', + headers:{'Content-Type':'application/json'}, + body: JSON.stringify({username: usernameInput.value, password: passwordInput.value}), + }); + if (r.ok) { + passwordInput.value = ''; + teardownAuthScreen(); + await startDashboard(); + return; + } + const j = await r.json().catch(() => ({})); + err.textContent = j.error || ('Sign-in failed: HTTP ' + r.status); + err.style.display = ''; + } catch (ex) { + err.textContent = 'Network error: ' + (ex && ex.message ? ex.message : ex); + err.style.display = ''; + } finally { + submit.disabled = false; + submit.textContent = 'Sign in'; + } + }}, [ + el('div', {class:'brand-row'}, [ + el('img', {src:'/assets/logo.svg', alt:''}), + el('div', {class:'name'}, 'OpenPXE'), + ]), + 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, + ]), + el('label', {class:'field'}, [ + el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Password'), + passwordInput, + ]), + submit, + ssoButton, + err, + el('div', {class:'auth-foot'}, 'OpenPXE · ' + (window.location.host || '')), + ]); + return form; + } + + function buildSetupCard() { + const usernameInput = el('input', {type:'text', name:'username', autocomplete:'username', autofocus:'autofocus', spellcheck:'false'}); + const passwordInput = el('input', {type:'password', name:'password', autocomplete:'new-password'}); + const confirmInput = el('input', {type:'password', name:'confirm', autocomplete:'new-password'}); + const err = el('div', {class:'auth-err', style:'display:none'}); + const submit = el('button', {class:'submit', type:'submit'}, 'Create administrator'); + + const form = el('form', {class:'auth-form', onsubmit: async (e) => { + e.preventDefault(); + err.style.display = 'none'; + if (passwordInput.value !== confirmInput.value) { + err.textContent = 'Passwords do not match.'; + err.style.display = ''; + return; + } + if (passwordInput.value.length < 8) { + err.textContent = 'Password must be at least 8 characters.'; + err.style.display = ''; + return; + } + submit.disabled = true; + submit.textContent = 'Creating…'; + try { + const r = await fetch('/api/setup', { + method:'POST', + headers:{'Content-Type':'application/json'}, + body: JSON.stringify({username: usernameInput.value, password: passwordInput.value}), + }); + if (r.ok) { + passwordInput.value = ''; + confirmInput.value = ''; + teardownAuthScreen(); + await startDashboard(); + return; + } + const j = await r.json().catch(() => ({})); + err.textContent = j.error || ('Setup failed: HTTP ' + r.status); + err.style.display = ''; + } catch (ex) { + err.textContent = 'Network error: ' + (ex && ex.message ? ex.message : ex); + err.style.display = ''; + } finally { + submit.disabled = false; + submit.textContent = 'Create administrator'; + } + }}, [ + el('div', {class:'brand-row'}, [ + el('img', {src:'/assets/logo.svg', alt:''}), + el('div', {class:'name'}, 'OpenPXE'), + ]), + el('h2', {}, 'First-run setup'), + el('p', {class:'lede'}, 'Welcome. Create the administrator account that will own this OpenPXE deployment. Additional users come in through SSO later.'), + el('label', {class:'field'}, [ + el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Username'), + usernameInput, + ]), + el('label', {class:'field'}, [ + el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Password (≥8 chars)'), + passwordInput, + ]), + el('label', {class:'field'}, [ + el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Confirm password'), + confirmInput, + ]), + submit, + err, + el('div', {class:'auth-foot'}, 'OpenPXE · ' + (window.location.host || '')), + ]); + return form; + } + + function showAuthScreen(mode) { + // Tear down any previous screen + the dashboard chrome. + if (authScreenEl && authScreenEl.parentNode) { + authScreenEl.parentNode.removeChild(authScreenEl); + } + const shell = document.querySelector('.shell'); + if (shell) shell.style.display = 'none'; + if (chipsInterval) { clearInterval(chipsInterval); chipsInterval = null; } + + const card = (mode === 'setup' ? buildSetupCard() : buildLoginCard()); + authScreenEl = el('div', {class:'auth-screen'}, + el('div', {class:'auth-card'}, card)); + document.body.appendChild(authScreenEl); + // Focus the first visible input (autofocus on dynamically created + // inputs doesn't fire in all browsers). + setTimeout(() => { + const inp = authScreenEl.querySelector('input[type="text"], input[type="password"]'); + if (inp) inp.focus(); + }, 30); + } + + async function startDashboard() { + // Light up the sidebar's "signed in as X / Sign out" row. It was + // hidden in index.html because we don't know the identity until + // /api/me resolves. + try { + const me = await fetch('/api/me').then(r => r.ok ? r.json() : null); + const row = $('[data-bind=logout_row]'); + const who = $('[data-bind=signed_in_as]'); + const btn = $('[data-bind=logout_btn]'); + if (row && me && me.authenticated && me.user) { + who.textContent = me.user.username; + who.title = 'Signed in as ' + me.user.username; + row.style.display = ''; + if (btn && !btn._wired) { + btn._wired = true; + btn.addEventListener('click', async () => { + await fetch('/api/logout', {method:'POST'}).catch(() => {}); + showAuthScreen('login'); + }); + } + } + } catch (e) { /* surfaces elsewhere */ } + render('dashboard'); + await refreshChips(); + if (!chipsInterval) chipsInterval = setInterval(refreshChips, 3000); + } + + async function bootstrap() { + let me; + try { + me = await fetch('/api/me').then(r => r.json()); + } catch (e) { + // /api/me is unauthenticated in every state — if we can't reach + // it the server is genuinely down, not an auth problem. + document.body.appendChild(el('div', {class:'auth-screen'}, + el('div', {class:'auth-card'}, [ + el('div', {class:'brand-row'}, [ + el('img', {src:'/assets/logo.svg', alt:''}), + el('div', {class:'name'}, 'OpenPXE'), + ]), + el('h2', {}, 'Connection error'), + el('p', {class:'lede'}, 'Could not reach the OpenPXE server. Refresh once it is back up.'), + ]))); + return; + } + // Preload the SSO config so the login card can offer the operator + // an "Sign in with X" button when configured. Failure is harmless. + try { ssoConfig = await fetch('/api/sso').then(r => r.ok ? r.json() : null); } + catch { ssoConfig = null; } + + if (me.setup_required) { + showAuthScreen('setup'); + return; + } + if (!me.authenticated) { + showAuthScreen('login'); + return; + } + await startDashboard(); + } + + bootstrap(); })(); diff --git a/crates/webui/src/index.html b/crates/webui/src/index.html index 0e9f894..92919ab 100644 --- a/crates/webui/src/index.html +++ b/crates/webui/src/index.html @@ -59,7 +59,15 @@ - + + + diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index a2f221e..e297047 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -28,6 +28,22 @@ RUN mkdir -p assets/ipxe && bash scripts/fetch-ipxe.sh FROM rust:${RUST_VERSION}-bookworm AS build WORKDIR /src +# v0.4.5: build a fully static musl binary (matches Bootimus v0.1.70's +# move). The resulting `/openpxe` has no glibc dependency at all, which: +# - Lets the runtime stage be any Linux distro (we still ship Debian +# slim for the `samba` / `wimtools` / `nfs-common` shellouts, but a +# scratch/distroless variant becomes a one-line swap). +# - Cuts a class of "GLIBC_2.39 not found" surprises when running on +# older RHEL/Rocky hosts that don't match Debian 12's libc version. +# - Sidesteps cross-compilation snags (the binary is its own world). +# +# x86_64-unknown-linux-musl is fully static by default (no extra +# RUSTFLAGS needed). musl-tools provides the linker. +RUN apt-get update \ + && apt-get install -y --no-install-recommends musl-tools \ + && rm -rf /var/lib/apt/lists/* \ + && rustup target add x86_64-unknown-linux-musl + # Copy the whole workspace in one go. We used to do a two-pass "cache-prime # with stubs, then real build" dance for dep-compile reuse; that turned out # to silently serve stale stub binaries when cargo's fingerprint didn't @@ -42,14 +58,14 @@ COPY Cargo.toml Cargo.lock ./ COPY crates/ crates/ COPY --from=fetch /src/assets/ipxe /src/assets/ipxe -# Cache cargo registry + target across builds. The `--no-edit` touch is +# Cache cargo registry + target across builds. The mtime touch is # belt-and-suspenders: cargo occasionally misses mtime-only changes on # networked FS; this forces a fingerprint check. RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/src/target,sharing=locked \ find crates -name '*.rs' -exec touch {} + && \ - cargo build --release --bin openpxe && \ - cp target/release/openpxe /openpxe && \ + cargo build --release --target x86_64-unknown-linux-musl --bin openpxe && \ + cp target/x86_64-unknown-linux-musl/release/openpxe /openpxe && \ ls -l /openpxe ########## runtime ########## @@ -62,22 +78,28 @@ RUN apt-get update \ && useradd --system --uid 10001 --home-dir /var/lib/openpxe --shell /usr/sbin/nologin openpxe \ && mkdir -p /var/lib/openpxe/isos /var/lib/openpxe/work /var/lib/openpxe/smb \ && chown -R openpxe:openpxe /var/lib/openpxe -# Runtime deps explained: -# wimtools - provides `wimlib-imagex`, used to inject startnet.cmd into boot.wim. -# samba - `smbd` serves extracted Windows install media on :445 for WinPE -# to `net use`. Guest read-only, scoped to /var/lib/openpxe/smb. -# nfs-common - provides `mount.nfs` / `mount.nfs4` for the Storage tab's -# NFS share manager. Mount also requires the container to run -# with CAP_SYS_ADMIN — without it, mount(2) returns EPERM and -# the manager surfaces a clear error in the UI instead of -# failing silently. -# iproute2 - `ip addr` / `ip route` for the auto-detected Network tab -# fields (NIC name, subnet mask, default gateway). Tiny, -# always available; we don't pull in netlink crates for -# this one-shot startup probe. -# gosu - drops privileges cleanly from root after the entrypoint fixes -# bind-mount ownership (common OpenShift/Docker UX issue). -# Windows-specific tools only activate when the WebUI toggle is on. +# v0.4.5: the openpxe binary itself is now built against musl and is +# fully static — no glibc dependency. The runtime stage still ships +# Debian slim because OpenPXE shells out to the four packages below for +# functionality we deliberately don't reimplement in-process: +# wimtools - `wimlib-imagex`, used to inject startnet.cmd into boot.wim. +# samba - `smbd` serves extracted Windows install media on :445 so +# WinPE can `net use`. Guest read-only, scoped to +# /var/lib/openpxe/smb. +# nfs-common - `mount.nfs` / `mount.nfs4` for the Storage tab's NFS +# share manager. Mount requires CAP_SYS_ADMIN; without it +# mount(2) returns EPERM and the manager surfaces a clear +# error in the UI. +# iproute2 - `ip addr` / `ip route` for the auto-detected Network +# tab fields (NIC name, subnet mask, default gateway). +# Tiny, always available; we don't pull in netlink crates +# for this one-shot startup probe. +# gosu - drops privileges cleanly from root after the entrypoint +# fixes bind-mount ownership (common OpenShift/Docker UX +# issue). +# A future "openpxe-static" variant could drop everything except the +# binary onto distroless once we move the Windows + NFS legs to +# in-process Rust crates. COPY --from=build /openpxe /usr/local/bin/openpxe COPY deploy/docker/entrypoint.sh /usr/local/bin/entrypoint.sh