Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1518110ed |
Generated
+10
-8
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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(_))));
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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();
|
||||
})();
|
||||
|
||||
@@ -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 v<span data-bind="version">0.4.4</span></div>
|
||||
<div class="footer-version">OpenPXE 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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user