Codebase-wide review pass: finish or remove every loose end, take the safe performance wins on the serving hot paths, and refresh the dependency tree for reliability. No behavior changes for working clients; legacy clients get clearer protocol errors. Finalize / cleanup: - Remove mac_allowlist/subnet_allowlist config fields — parsed but never enforced since introduction; the operator wants line-of-sight serving, so the honest fix is deletion, not wiring. - Remove dead ClientRegistry API (get, set_selected_target, always-None selected_target field, never-emitted DhcpRequest/ HttpIsoAsset events). - TFTP: reject WRQ with ERR_ILLEGAL_OP and non-octet modes with a clear error instead of silent timeouts (legacy-client friendliness); fold plan_window into cfg(test); drop the unused-constant keep-alive hack. - rustfmt sweep over the six files with accumulated drift. Hot-path optimizations (all behavior-preserving): - Serve embedded iPXE binaries zero-copy (Cow over rodata) on both TFTP and HTTP — was a ~1 MiB heap copy per boot file request. - Cache the composited PXE boot-menu background PNG keyed on the branding logo revision — was ~50-200 ms of image work per booting client; now one compose per logo change. - Run bcrypt verify/hash on the blocking pool (boot password gate, login, setup, credential rotation) so CPU-heavy auth can't stall the workers streaming ISO ranges to imaging machines. - iso_raw: reuse the already-cloned IsoMeta for path resolution instead of a second registry lock + deep clone per range request. - DriverEscalation: amortize the TTL sweep (1-min interval + inline staleness check) instead of an O(map) retain per DHCP packet. - format_mac: one allocation instead of four per datagram. - Introspection haystack sized to min(scan cap, file size) — was guaranteed a 32 MiB realloc on every large-ISO probe. Robustness: - parse_range: malformed Range headers are now ignored per RFC 7233 (200 + full body) instead of answered with a bogus 206. Dependencies: - dhcproto 0.12 -> 0.15: drops the deprecated/unmaintained trust-dns-proto from the tree (hickory-proto), three releases of DHCP option coverage. Compiles + passes the full suite unchanged. - socket2 0.6 (dedupes tree), bcrypt 0.19, tower-http 0.6.11 (sheds iri-string), tokio 1.52.3 / hyper 1.10 lockfile refresh; dead nom workspace entry removed; requested versions synced to shipped reality. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
352 lines
12 KiB
Rust
352 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());
|
|
}
|
|
}
|