v0.4.5: VMware UEFI fix, static musl binary, Forms auth + SSO config

VMware UEFI / Casper boot fix:
- Linux cmdline for Debian/Ubuntu/Mint/Pop!_OS/elementary now uses the
  canonical Casper `iso-url=` option and `ds=nocloud`, matching the
  fix Bootimus shipped in v0.1.67. The previous
  `boot=casper netboot=url url=… ip=dhcp ---` form booted fine on
  bare-metal UEFI but hung at "cloud-init running" on VMware guests
  because subiquity / cloud-init can't reach a metadata datasource
  through PXE.

Static binary (matches Bootimus v0.1.70):
- Dockerfile build stage now compiles against
  x86_64-unknown-linux-musl. The resulting /openpxe has no glibc
  dependency at all; the runtime stage still ships Debian slim for the
  samba/wimtools/nfs-common shellouts, but a future scratch/distroless
  variant is now a one-line swap. Cuts a class of "GLIBC_2.39 not
  found" surprises on older RHEL/Rocky hosts.

Forms auth (Sonarr/Radarr-style):
- New AdminStore in openpxe-core: single admin record persisted to
  <work_dir>/auth.json, bcrypt-hashed credentials, rotation requires
  current password.
- New SessionStore in openpxe-http-api: in-memory UUID-keyed sessions
  with 24h sliding TTL, openpxe_session HttpOnly cookie.
- Endpoints: POST /api/setup (first-run), POST /api/login, POST
  /api/logout, GET /api/me, PUT /api/me/credentials (rotates and
  revokes every other session).
- Auth middleware gates /api/* once the admin is configured;
  passes through entirely until then (tests + fresh installs ride this
  path). Allowlists PXE-essential paths (/boot.ipxe, /iso/*, /ipxe/*,
  /api/queue/join, /api/queue/poll/*) so iPXE clients still work
  without a cookie they can't send.
- WebUI: first-run setup card, login card, logout chip in the sidebar
  footer, Account card in Settings for rotating creds. Auth screen is
  fully styled (centered narrow card, matches Sonarr layout).

SSO config (FleetDM-shaped, storage-only):
- New SsoStore in openpxe-core: { enabled, idp_name, metadata,
  metadata_url } persisted to <work_dir>/sso.json with size caps and
  URL-scheme validation.
- Endpoints: GET /api/sso, PUT /api/sso. Validation: enabling SSO
  without either metadata or metadata_url returns 400.
- WebUI: SSO card in Settings with a URL-vs-XML mode switch and an
  inert "Sign in with X" button on the login screen while runtime
  flow is pending. Per the brief: no Entity ID field (defaults to the
  advertised public_base_url internally when SAML wiring lands).

Quality:
- 132 tests passing (was 106 in v0.4.4): +5 auth unit tests, +5 SSO
  unit tests, +7 auth integration tests, +1 SSO integration test, +1
  regression guard pinning the new Casper cmdline.
- cargo clippy --workspace --all-targets clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Miles Ward
2026-05-25 22:37:20 -04:00
co-authored by Claude Opus 4.7
parent 55d74662c2
commit 4a354a8664
18 changed files with 2149 additions and 61 deletions
Generated
+10 -8
View File
@@ -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"
+1 -1
View File
@@ -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"
+3
View File
@@ -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"
+353
View File
@@ -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<AdminAccount>,
}
/// In-memory + on-disk admin registry. Cheap to clone.
#[derive(Debug, Clone)]
pub struct AdminStore {
path: Arc<PathBuf>,
inner: Arc<RwLock<Inner>>,
}
impl AdminStore {
/// Load from `<work_dir>/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::<Inner>(&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<AdminPublic> {
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<AdminPublic> {
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<Option<AdminPublic>> {
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<AdminPublic> {
// 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<String> {
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());
}
}
+4
View File
@@ -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};
+267
View File
@@ -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 <https://github.com/fleetdm/fleet>'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<PathBuf>,
inner: Arc<RwLock<SsoConfig>>,
}
impl SsoStore {
/// Load from `<work_dir>/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::<SsoConfig>(&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<SsoConfig> {
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#"<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata">test</EntityDescriptor>"#;
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(_))));
}
}
+2
View File
@@ -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"] }
+69 -1
View File
@@ -13,6 +13,7 @@
//! | `/iso/<id>/*` | 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<AppState>) -> Json<SsoConfig> {
// 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<AppState>, Json(body): Json<SsoConfig>) -> 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<AppState>) -> Response {
@@ -896,10 +945,29 @@ async fn api_docs() -> Json<serde_json::Value> {
"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": [
+482
View File
@@ -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<RwLock<HashMap<String, Session>>>,
}
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<String> {
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<i64>) -> 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<String> {
// `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<AppState>` reaches in for the admin store +
/// session store.
pub async fn require_auth(
State(state): State<AppState>,
req: Request<Body>,
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<AppState>,
Json(body): Json<SetupBody>,
) -> 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<AppState>, Json(body): Json<LoginBody>) -> 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<AppState>,
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<AppState>, 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<String>,
#[serde(default)]
pub new_password: Option<String>,
}
/// 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<AppState>,
Json(body): Json<UpdateCredentialsBody>,
) -> 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());
}
}
+1
View File
@@ -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;
+14 -2
View File
@@ -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,
+336
View File
@@ -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<u8>, Vec<axum::http::HeaderValue>) {
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<String> {
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<u8>) {
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<String> = 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");
}
}
+27 -1
View File
@@ -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(),
+6
View File
@@ -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(),
+80
View File
@@ -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); }
+444 -28
View File
@@ -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:'<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"…',
style:'width:100%;font-family:var(--mono);font-size:12px;background:var(--bg);' +
'color:var(--fg);border:1px solid var(--border);border-radius:var(--radius);' +
'padding:8px 10px;resize:vertical'},
sso.metadata || '');
const ssoMode = el('select', {style:'min-width:160px;background:var(--bg);color:var(--fg);' +
'border:1px solid var(--border);border-radius:var(--radius);padding:6px 8px;font:inherit'}, [
el('option', {value:'url'}, 'Metadata URL'),
el('option', {value:'xml'}, 'Metadata XML'),
]);
ssoMode.value = sso.metadata && !sso.metadata_url ? 'xml' : 'url';
const urlWrap = el('label', {class:'field'}, [
el('span', {class:'name'}, 'IdP metadata URL'),
ssoUrl,
el('span', {class:'hint'},
'OpenPXE will fetch this URL once SSO sign-in lands; v0.4.5 just stores it.'),
]);
const xmlWrap = el('label', {class:'field'}, [
el('span', {class:'name'}, 'IdP metadata XML'),
ssoXml,
el('span', {class:'hint'},
'Paste the raw <EntityDescriptor>…</EntityDescriptor> 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();
})();
+9 -1
View File
@@ -59,7 +59,15 @@
<!-- The brand badge at the top can be overridden by operator-uploaded
logos; keep "OpenPXE v…" pinned in the footer so the backend
identity is always visible regardless of branding. -->
<div class="footer-version">OpenPXE&nbsp;v<span data-bind="version">0.4.4</span></div>
<div class="footer-version">OpenPXE&nbsp;v<span data-bind="version">0.4.5</span></div>
<!-- v0.4.5: signed-in identity + one-click sign-out. The button
is populated by app.js after /api/me reports an authenticated
session — pre-auth states swap the whole shell for the
login/setup card so this row never gets shown there. -->
<div class="logout-row" data-bind="logout_row" style="display:none">
<span class="who" data-bind="signed_in_as" title=""></span>
<button type="button" class="logout-btn" data-bind="logout_btn">Sign out</button>
</div>
</div>
</aside>
+41 -19
View File
@@ -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