VMware UEFI / Casper boot fix:
- Linux cmdline for Debian/Ubuntu/Mint/Pop!_OS/elementary now uses the
canonical Casper `iso-url=` option and `ds=nocloud`, matching the
fix Bootimus shipped in v0.1.67. The previous
`boot=casper netboot=url url=… ip=dhcp ---` form booted fine on
bare-metal UEFI but hung at "cloud-init running" on VMware guests
because subiquity / cloud-init can't reach a metadata datasource
through PXE.
Static binary (matches Bootimus v0.1.70):
- Dockerfile build stage now compiles against
x86_64-unknown-linux-musl. The resulting /openpxe has no glibc
dependency at all; the runtime stage still ships Debian slim for the
samba/wimtools/nfs-common shellouts, but a future scratch/distroless
variant is now a one-line swap. Cuts a class of "GLIBC_2.39 not
found" surprises on older RHEL/Rocky hosts.
Forms auth (Sonarr/Radarr-style):
- New AdminStore in openpxe-core: single admin record persisted to
<work_dir>/auth.json, bcrypt-hashed credentials, rotation requires
current password.
- New SessionStore in openpxe-http-api: in-memory UUID-keyed sessions
with 24h sliding TTL, openpxe_session HttpOnly cookie.
- Endpoints: POST /api/setup (first-run), POST /api/login, POST
/api/logout, GET /api/me, PUT /api/me/credentials (rotates and
revokes every other session).
- Auth middleware gates /api/* once the admin is configured;
passes through entirely until then (tests + fresh installs ride this
path). Allowlists PXE-essential paths (/boot.ipxe, /iso/*, /ipxe/*,
/api/queue/join, /api/queue/poll/*) so iPXE clients still work
without a cookie they can't send.
- WebUI: first-run setup card, login card, logout chip in the sidebar
footer, Account card in Settings for rotating creds. Auth screen is
fully styled (centered narrow card, matches Sonarr layout).
SSO config (FleetDM-shaped, storage-only):
- New SsoStore in openpxe-core: { enabled, idp_name, metadata,
metadata_url } persisted to <work_dir>/sso.json with size caps and
URL-scheme validation.
- Endpoints: GET /api/sso, PUT /api/sso. Validation: enabling SSO
without either metadata or metadata_url returns 400.
- WebUI: SSO card in Settings with a URL-vs-XML mode switch and an
inert "Sign in with X" button on the login screen while runtime
flow is pending. Per the brief: no Entity ID field (defaults to the
advertised public_base_url internally when SAML wiring lands).
Quality:
- 132 tests passing (was 106 in v0.4.4): +5 auth unit tests, +5 SSO
unit tests, +7 auth integration tests, +1 SSO integration test, +1
regression guard pinning the new Casper cmdline.
- cargo clippy --workspace --all-targets clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
354 lines
12 KiB
Rust
354 lines
12 KiB
Rust
//! 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());
|
|
}
|
|
}
|