diff --git a/Cargo.lock b/Cargo.lock index fb8128a..da263fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -175,45 +175,6 @@ dependencies = [ "password-hash", ] -[[package]] -name = "asn1-rs" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" -dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", - "displaydoc", - "nom 7.1.3", - "num-traits", - "rusticata-macros", - "thiserror", - "time", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "asn1-rs-impl" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "assert-json-diff" version = "2.0.2" @@ -1144,20 +1105,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "der-parser" -version = "10.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" -dependencies = [ - "asn1-rs", - "displaydoc", - "nom 7.1.3", - "num-bigint", - "num-traits", - "rusticata-macros", -] - [[package]] name = "der_derive" version = "0.7.3" @@ -2445,7 +2392,7 @@ dependencies = [ "httpdate", "idna", "mime", - "nom 8.0.0", + "nom", "percent-encoding", "quoted_printable", "rustls", @@ -2564,12 +2511,6 @@ dependencies = [ "unicase", ] -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2702,16 +2643,6 @@ dependencies = [ "libc", ] -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "nom" version = "8.0.0" @@ -2803,15 +2734,6 @@ dependencies = [ "libc", ] -[[package]] -name = "oid-registry" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" -dependencies = [ - "asn1-rs", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -2836,7 +2758,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openpxe" -version = "0.7.5" +version = "0.8.0" dependencies = [ "anyhow", "axum", @@ -2848,17 +2770,15 @@ dependencies = [ "openpxe-ipxe-assets", "openpxe-iso-store", "openpxe-tftp", - "serde", "time", "tokio", - "toml", "tracing", "tracing-subscriber", ] [[package]] name = "openpxe-core" -version = "0.7.5" +version = "0.8.0" dependencies = [ "anyhow", "base64", @@ -2867,7 +2787,6 @@ dependencies = [ "figment", "flate2", "parking_lot", - "quick-xml", "rcgen", "roxmltree", "serde", @@ -2880,29 +2799,26 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", - "x509-parser", ] [[package]] name = "openpxe-dhcp-proxy" -version = "0.7.5" +version = "0.8.0" dependencies = [ "anyhow", - "bytes", "dhcproto", "openpxe-core", "parking_lot", "serde_json", "socket2", "tempfile", - "thiserror", "tokio", "tracing", ] [[package]] name = "openpxe-http-api" -version = "0.7.5" +version = "0.8.0" dependencies = [ "anyhow", "axum", @@ -2910,7 +2826,6 @@ dependencies = [ "bergshamra", "bytes", "futures", - "hyper", "image", "insta", "lettre", @@ -2924,7 +2839,6 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror", "time", "tokio", "tokio-stream", @@ -2938,17 +2852,16 @@ dependencies = [ [[package]] name = "openpxe-ipxe-assets" -version = "0.7.5" +version = "0.8.0" dependencies = [ "openpxe-core", "rust-embed", - "thiserror", "tracing", ] [[package]] name = "openpxe-iso-store" -version = "0.7.5" +version = "0.8.0" dependencies = [ "anyhow", "bcrypt", @@ -2967,31 +2880,26 @@ dependencies = [ "serde_json", "sha2 0.10.9", "tempfile", - "thiserror", "time", "tokio", - "tokio-util", "tracing", - "uuid", ] [[package]] name = "openpxe-tftp" -version = "0.7.5" +version = "0.8.0" dependencies = [ "anyhow", - "bytes", "openpxe-core", "openpxe-ipxe-assets", "socket2", - "thiserror", "tokio", "tracing", ] [[package]] name = "openpxe-webui" -version = "0.7.5" +version = "0.8.0" [[package]] name = "p256" @@ -3438,15 +3346,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quick-xml" -version = "0.40.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2474bd2e5029e7ccb6abb2ba48cf2383a333851dedf495901544281590c7da7f" -dependencies = [ - "memchr", -] - [[package]] name = "quinn" version = "0.11.9" @@ -3933,15 +3832,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom 7.1.3", -] - [[package]] name = "rustix" version = "1.1.4" @@ -5644,23 +5534,6 @@ dependencies = [ "tls_codec", ] -[[package]] -name = "x509-parser" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" -dependencies = [ - "asn1-rs", - "data-encoding", - "der-parser", - "lazy_static", - "nom 7.1.3", - "oid-registry", - "rusticata-macros", - "thiserror", - "time", -] - [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 3dbfbdf..9aee6e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.7.5" +version = "0.8.0" edition = "2021" rust-version = "1.95" license = "MIT OR Apache-2.0" @@ -82,8 +82,6 @@ lettre = { version = "0.11", default-features = false, features = ["smtp-transpo # SP layer (AuthnRequest, metadata parse, SAMLResponse semantics) on top. bergshamra = "0.5" roxmltree = "0.21" -quick-xml = "0.40" -x509-parser = "0.18" # flate2 default backend is miniz_oxide (pure Rust) — do NOT enable the # zlib/zlib-ng C backends, which would break the musl-static build. flate2 = "1.1" diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index af61fce..44f7f2a 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -27,13 +27,11 @@ tokio = { workspace = true, features = ["sync", "rt", "macros", "time"] } bcrypt.workspace = true # v0.5.1: pure-Rust SAML 2.0 SP. bergshamra = XML-DSig verify + exclusive -# c14n (no OpenSSL/C). roxmltree/quick-xml parse + build SAML XML; -# x509-parser pulls the IdP signing cert out of metadata; flate2+base64 -# encode the HTTP-Redirect binding's SAMLRequest. +# c14n (no OpenSSL/C), plus IdP signing-cert extraction from metadata. +# roxmltree parses the SAML/metadata XML; flate2+base64 encode the +# HTTP-Redirect binding's SAMLRequest. bergshamra.workspace = true roxmltree.workspace = true -quick-xml.workspace = true -x509-parser.workspace = true flate2.workspace = true base64.workspace = true diff --git a/crates/core/src/api_key.rs b/crates/core/src/api_key.rs new file mode 100644 index 0000000..ea614d5 --- /dev/null +++ b/crates/core/src/api_key.rs @@ -0,0 +1,158 @@ +//! Operator API key — a single persisted secret that authenticates +//! programmatic `/api/*` callers (Postman, scripts, CI) via the +//! `x-api-key` header, as an alternative to the browser session cookie. +//! +//! Generated on first load and persisted to `/api_key.json` so +//! it survives restarts — an operator pastes it into their client once. +//! Regenerable from Settings → Advanced; the previous key stops working +//! the moment a new one is minted. Grants the same access as a logged-in +//! operator (the middleware treats a valid key exactly like a session). + +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::Arc; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ApiKeyFile { + key: String, +} + +/// Persisted operator API key. Cheap to clone (Arc-shared); contention is +/// nil (read on every authenticated request, written only on regenerate). +#[derive(Debug, Clone)] +pub struct ApiKeyStore { + path: Arc, + inner: Arc>, +} + +impl ApiKeyStore { + /// Load the stored key, minting + persisting a fresh one on first run + /// (or when the file is missing / corrupt / empty). + #[must_use] + pub fn load_or_init(work_dir: &std::path::Path) -> Self { + let path = work_dir.join("api_key.json"); + let key = match std::fs::read_to_string(&path) { + Ok(text) => serde_json::from_str::(&text) + .map(|f| f.key) + .ok() + .filter(|k| !k.is_empty()) + .unwrap_or_else(generate_key), + Err(_) => generate_key(), + }; + let store = Self { + path: Arc::new(path), + inner: Arc::new(RwLock::new(key)), + }; + // Land a first-run (or repaired) key on disk immediately so it's + // stable across the very next restart. + store.persist(); + store + } + + #[must_use] + pub fn current(&self) -> String { + self.inner.read().clone() + } + + /// Constant-time comparison against the stored key. An empty candidate + /// never matches, so a blank/absent header can't authenticate. + #[must_use] + pub fn verify(&self, candidate: &str) -> bool { + if candidate.is_empty() { + return false; + } + ct_eq(self.inner.read().as_bytes(), candidate.as_bytes()) + } + + /// Mint a fresh key, persist it, and return it. The previous key is + /// invalid the instant this returns. + #[must_use] + pub fn regenerate(&self) -> String { + let key = generate_key(); + self.inner.write().clone_from(&key); + self.persist(); + tracing::info!(target: "openpxe::auth", "operator API key regenerated"); + key + } + + fn persist(&self) { + let body = match serde_json::to_vec_pretty(&ApiKeyFile { + key: self.current(), + }) { + Ok(b) => b, + Err(e) => { + tracing::warn!(target: "openpxe::auth", "serialize api_key.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 api_key.json tmp: {e}"); + return; + } + if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) { + tracing::warn!(target: "openpxe::auth", "rename api_key.json: {e}"); + } + } +} + +/// 128 random bits as 32 lowercase hex chars — unambiguous to copy-paste +/// into an `x-api-key` header. UUID v4 is already our CSPRNG-backed source +/// for session ids, so no new dependency. +fn generate_key() -> String { + Uuid::new_v4().simple().to_string() +} + +/// Length-checked constant-time byte compare — keeps key verification from +/// leaking the matched-prefix length via timing. A 128-bit random secret +/// isn't practically timing-attackable over a network, but the check is +/// four lines, so we keep it. +fn ct_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn generates_persists_and_reloads() { + let dir = tempdir().unwrap(); + let s = ApiKeyStore::load_or_init(dir.path()); + let k = s.current(); + assert_eq!(k.len(), 32, "32 hex chars = 128 bits"); + assert!(s.verify(&k)); + assert!(!s.verify("wrong")); + assert!(!s.verify(""), "blank header must not authenticate"); + // Reload from disk → same key (survives restart). + let s2 = ApiKeyStore::load_or_init(dir.path()); + assert_eq!(s2.current(), k); + } + + #[test] + fn regenerate_invalidates_old() { + let dir = tempdir().unwrap(); + let s = ApiKeyStore::load_or_init(dir.path()); + let old = s.current(); + let new = s.regenerate(); + assert_ne!(old, new); + assert!(s.verify(&new)); + assert!(!s.verify(&old), "old key must stop working"); + // Persisted: a reload sees the new key. + let s2 = ApiKeyStore::load_or_init(dir.path()); + assert_eq!(s2.current(), new); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 0426564..9fc97ac 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -2,6 +2,7 @@ //! runtime settings, and the Queued Deployment queue. #![forbid(unsafe_code)] +pub mod api_key; pub mod arch; pub mod auth; pub mod boot_log; @@ -23,6 +24,7 @@ pub mod settings; pub mod sso; pub mod wol; +pub use api_key::ApiKeyStore; pub use arch::{ClientArch, DriverMode, FirmwareClass}; pub use auth::{AdminAccount, AdminPublic, AdminStore}; pub use boot_log::{BootEvent, BootLog}; diff --git a/crates/dhcp-proxy/Cargo.toml b/crates/dhcp-proxy/Cargo.toml index dc9e673..e979207 100644 --- a/crates/dhcp-proxy/Cargo.toml +++ b/crates/dhcp-proxy/Cargo.toml @@ -15,9 +15,7 @@ tokio.workspace = true socket2.workspace = true dhcproto.workspace = true tracing.workspace = true -thiserror.workspace = true anyhow.workspace = true -bytes.workspace = true parking_lot.workspace = true # v0.7.1: learned driver modes persist to /driver_modes.json. serde_json.workspace = true diff --git a/crates/http-api/Cargo.toml b/crates/http-api/Cargo.toml index 01b5c34..3d19ce6 100644 --- a/crates/http-api/Cargo.toml +++ b/crates/http-api/Cargo.toml @@ -25,11 +25,9 @@ time.workspace = true axum.workspace = true tower.workspace = true tower-http.workspace = true -hyper.workspace = true serde.workspace = true serde_json.workspace = true tracing.workspace = true -thiserror.workspace = true anyhow.workspace = true bytes.workspace = true futures.workspace = true diff --git a/crates/http-api/src/app.rs b/crates/http-api/src/app.rs index 207596e..9a94eb7 100644 --- a/crates/http-api/src/app.rs +++ b/crates/http-api/src/app.rs @@ -147,6 +147,14 @@ pub fn build_router(state: AppState) -> Router { .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.8.0: operator API key surface (read current + regenerate). + // Gated by require_auth like the rest of /api/*; a logged-in + // operator or an x-api-key holder can read/rotate it. + .route("/api/api-key", get(auth_api::api_api_key_get)) + .route( + "/api/api-key/regenerate", + post(auth_api::api_api_key_regenerate), + ) // SAML SSO configuration (FleetDM-shaped). Gated behind auth — the // operator pastes their IdP metadata, Entity ID, and toggles here. .route("/api/sso", get(api_sso_get).put(api_sso_put)) @@ -1944,6 +1952,10 @@ async fn api_docs() -> Json { "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."}, + {"method": "GET", "path": "/api/api-key", + "summary": "Return the operator API key + the header to send it in (x-api-key). That header authenticates API calls without a browser session — full operator access."}, + {"method": "POST", "path": "/api/api-key/regenerate", + "summary": "Mint a fresh API key, invalidating the previous one immediately."}, ], }, { diff --git a/crates/http-api/src/auth.rs b/crates/http-api/src/auth.rs index 287098d..a1b60f3 100644 --- a/crates/http-api/src/auth.rs +++ b/crates/http-api/src/auth.rs @@ -52,6 +52,12 @@ const SESSION_TTL: Duration = Duration::from_hours(24); /// to avoid collisions with anything else sharing the host. pub const SESSION_COOKIE: &str = "openpxe_session"; +/// Header an API client sends to authenticate without a browser session. +/// Matches the de-facto `x-api-key` convention operators already use with +/// other appliances. A valid key grants the same access as a logged-in +/// operator. See [`crate::state::AppState::api_key`]. +pub const API_KEY_HEADER: &str = "x-api-key"; + #[derive(Debug, Clone)] struct Session { username: String, @@ -223,13 +229,18 @@ pub async fn require_auth( 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; - } + // Authenticated path: either a live operator session cookie (the + // browser) or the x-api-key header (scripts / Postman). Touching the + // cookie refreshes its idle TTL as a side-effect. + let session_ok = + parse_cookie(req.headers()).is_some_and(|t| state.sessions.touch(&t).is_some()); + let key_ok = req + .headers() + .get(API_KEY_HEADER) + .and_then(|v| v.to_str().ok()) + .is_some_and(|k| state.api_key.verify(k)); + if session_ok || key_ok { + return next.run(req).await; } ( StatusCode::UNAUTHORIZED, @@ -447,6 +458,28 @@ pub async fn api_update_credentials( } } +/// Return the current operator API key plus the header to send it in. +/// Gated by the auth middleware, so only a logged-in operator (or a +/// caller already holding the key) can read it. +pub async fn api_api_key_get(State(state): State) -> Response { + ( + StatusCode::OK, + Json(json!({ "key": state.api_key.current(), "header": API_KEY_HEADER })), + ) + .into_response() +} + +/// Mint a fresh operator API key, invalidating the previous one, and +/// return it. Same gating as the GET. +pub async fn api_api_key_regenerate(State(state): State) -> Response { + let key = state.api_key.regenerate(); + ( + StatusCode::OK, + Json(json!({ "key": key, "header": API_KEY_HEADER })), + ) + .into_response() +} + #[derive(Debug, Serialize)] struct LoginPayload<'a> { user: &'a AdminPublic, diff --git a/crates/http-api/src/state.rs b/crates/http-api/src/state.rs index ed37349..db25203 100644 --- a/crates/http-api/src/state.rs +++ b/crates/http-api/src/state.rs @@ -2,7 +2,7 @@ use crate::auth::SessionStore; use crate::saml_routes::SamlRuntime; use crate::uploads::UploadSessions; use openpxe_core::{ - AdminStore, BootLog, BootRulesStore, BootTokens, BrandingStore, ClientRegistry, + AdminStore, ApiKeyStore, BootLog, BootRulesStore, BootTokens, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, NotifyStore, SettingsStore, SsoStore, }; use openpxe_iso_store::{ @@ -55,6 +55,11 @@ pub struct AppState { /// process restart (sessions are tied to UI state, not persisted — /// matches Sonarr/Radarr behaviour). pub sessions: SessionStore, + /// v0.8.0: persisted operator API key. A request carrying a matching + /// `x-api-key` header authenticates exactly like an operator session, + /// so scripts / Postman can drive `/api/*` without a browser login. + /// Generated on first run; regenerable from Settings → Advanced. + pub api_key: ApiKeyStore, /// SAML SSO configuration (persisted IdP metadata, Entity ID, toggles). pub sso: SsoStore, /// v0.5.1: in-memory SAML runtime state — outstanding AuthnRequest IDs diff --git a/crates/http-api/tests/full_flow.rs b/crates/http-api/tests/full_flow.rs index 8dc72d0..25ba4de 100644 --- a/crates/http-api/tests/full_flow.rs +++ b/crates/http-api/tests/full_flow.rs @@ -99,6 +99,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) { let admin = openpxe_core::AdminStore::load_or_default(dir.path()); let sso = openpxe_core::SsoStore::load_or_default(dir.path()); let notify = openpxe_core::NotifyStore::load_or_default(dir.path()); + let api_key = openpxe_core::ApiKeyStore::load_or_init(dir.path()); let sessions = openpxe_http_api::auth::SessionStore::default(); let metrics = Metrics::new(); let state = AppState { @@ -114,6 +115,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) { pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(), admin, sessions, + api_key, sso, saml: openpxe_http_api::saml_routes::SamlRuntime::default(), notify, @@ -135,6 +137,60 @@ async fn build_state() -> (AppState, tempfile::TempDir) { (state, dir) } +#[tokio::test] +async fn api_key_authenticates_gated_endpoints() { + // v0.8.0: the x-api-key header authenticates /api/* like an operator + // session. The middleware only enforces once an admin is configured + // (before that everything is open), so bootstrap one first. + let (state, _dir) = build_state().await; + state + .admin + .bootstrap("admin", "correct-horse-battery-staple") + .unwrap(); + let key = state.api_key.current(); + let app = build_router(state); + + // No credentials → 401. + let res = app + .clone() + .oneshot( + Request::builder() + .uri("/api/isos") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "no auth must 401"); + + // Wrong key → 401. + let res = app + .clone() + .oneshot( + Request::builder() + .uri("/api/isos") + .header("x-api-key", "not-the-key") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "wrong key must 401"); + + // Correct key → 200 (operator-equivalent access). + let res = app + .oneshot( + Request::builder() + .uri("/api/isos") + .header("x-api-key", key) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK, "valid key must authenticate"); +} + #[tokio::test] async fn health_and_ready_endpoints() { let (state, _dir) = build_state().await; diff --git a/crates/ipxe-assets/Cargo.toml b/crates/ipxe-assets/Cargo.toml index 69593bc..35a94f2 100644 --- a/crates/ipxe-assets/Cargo.toml +++ b/crates/ipxe-assets/Cargo.toml @@ -13,4 +13,3 @@ workspace = true openpxe-core.workspace = true rust-embed.workspace = true tracing.workspace = true -thiserror.workspace = true diff --git a/crates/iso-store/Cargo.toml b/crates/iso-store/Cargo.toml index 201a4b5..f8e82bc 100644 --- a/crates/iso-store/Cargo.toml +++ b/crates/iso-store/Cargo.toml @@ -12,16 +12,13 @@ workspace = true [dependencies] openpxe-core.workspace = true tokio = { workspace = true } -tokio-util = { workspace = true } serde.workspace = true serde_json.workspace = true tracing.workspace = true -thiserror.workspace = true anyhow.workspace = true sha2.workspace = true hex.workspace = true bcrypt.workspace = true -uuid.workspace = true time.workspace = true parking_lot.workspace = true bytes.workspace = true diff --git a/crates/iso-store/src/introspect.rs b/crates/iso-store/src/introspect.rs index edcf885..a1356b0 100644 --- a/crates/iso-store/src/introspect.rs +++ b/crates/iso-store/src/introspect.rs @@ -60,7 +60,12 @@ pub enum DistroFamily { /// descriptor scans. Without this bump, images the rev-2 logic flagged /// as data ISOs (mangled-primary appliance images, filler-sector boot /// records) would never re-probe and stay mislabeled. -pub const INTROSPECT_REV: u32 = 3; +/// rev 4 (v0.8.0): dropped the over-broad "microsoft" UTF-16 bulk-scan +/// marker that classified any Secure-Boot-signed non-Windows bootable +/// (memtest86, signed BSDs, firmware tools) as Windows — the string +/// lives in the FAT long-filename entries of their MS-signed EFI loader. +/// The bump re-probes those so they drop the bogus Windows label. +pub const INTROSPECT_REV: u32 = 4; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct IntrospectionReport { @@ -414,7 +419,13 @@ async fn bulk_windows_scan(r: &mut R, total_len: u64) -> Op return Some(true); } let ascii_markers: [&[u8]; 3] = [b"bootmgr", b"sources/install.wim", b"sources/install.esd"]; - let utf16_markers = ["bootmgr", "install.wim", "microsoft"]; + // v0.8.0: dropped the bare "microsoft" marker. It matched the + // Microsoft-signed Secure-Boot EFI loader that memtest86 (and signed + // BSDs / firmware tools) ship — the string lives in the loader's FAT + // long-filename entries — so any signed non-Windows bootable + // false-classified as Windows. The remaining markers are all + // Windows-exclusive filenames. + let utf16_markers = ["bootmgr", "install.wim"]; let hit = ascii_markers.iter().any(|m| contains_ascii(&haystack, m)) || utf16_markers .iter() @@ -775,4 +786,26 @@ mod tests { assert_eq!(r.family, DistroFamily::WindowsPe); assert!(!r.has_boot_wim); } + + #[test] + fn memtest_signed_efi_is_not_windows() { + // v0.8.0 regression: PassMark MemTest86 ships a Microsoft-signed + // Secure-Boot EFI loader, and "Microsoft" appears in its FAT + // long-filename entries as UTF-16LE. The old bulk-scan "microsoft" + // marker classified it (and any signed BSD / firmware tool) as + // Windows. It must now classify as a generic bootable (sanboot). + let mut img = TestIsoBuilder::new("MEMTEST86") + .el_torito(true) + .file("/EFI/BOOT/BOOTX64.EFI", b"signed-efi-app") + .build(); + let marker: Vec = "Microsoft".bytes().flat_map(|b| [b, 0]).collect(); + img.extend_from_slice(&marker); + let r = introspect_mem(img, "memtest86-iso.iso", true); + assert_ne!( + r.family, + DistroFamily::WindowsPe, + "a Microsoft-signed EFI loader is not Windows media" + ); + assert!(r.el_torito, "still a bootable image"); + } } diff --git a/crates/iso-store/src/store.rs b/crates/iso-store/src/store.rs index c21ad03..b30b5ac 100644 --- a/crates/iso-store/src/store.rs +++ b/crates/iso-store/src/store.rs @@ -300,7 +300,19 @@ impl IsoStore { } let partial_path = self.iso_dir.join(format!("{id}.partial")); if partial_path.exists() { - return Err(Error::Invalid(format!("iso '{id}' is already uploading"))); + // A leftover .partial is an upload abandoned mid-flight (browser + // refresh, tab close, dropped connection) — nothing reaps it + // otherwise, and the operator hits a bogus "already uploading" + // on retry. The chunked protocol can't resume it anyway (a + // fresh session restarts at offset 0), so reclaim it. + // ponytail: two tabs uploading the *same filename* at once would + // race here — last writer wins, and the truncating create below + // keeps that from corrupting a half-written file. + tracing::info!( + target: "openpxe::iso", %id, + "reclaiming abandoned .partial from a prior upload attempt" + ); + tokio::fs::remove_file(&partial_path).await.ok(); } let file = tokio::fs::File::create(&partial_path).await?; Ok(UploadHandle { @@ -872,15 +884,39 @@ mod tests { } #[tokio::test] - async fn begin_upload_rejects_existing_partial_file() { + async fn begin_upload_reclaims_stale_partial_file() { + // v0.8.0: an abandoned .partial (browser refresh / crash / dropped + // connection) must not block a re-upload with a bogus "already + // uploading" — begin_upload reclaims it and starts fresh, since the + // chunked protocol can't resume a dead session anyway. let dir = tempdir().unwrap(); let store = IsoStore::new(dir.path().to_path_buf()); store.ensure_dirs().await.unwrap(); - tokio::fs::write(dir.path().join("ubuntu.partial"), b"in-flight") + let partial = dir.path().join("ubuntu.partial"); + tokio::fs::write(&partial, b"in-flight").await.unwrap(); + + let handle = store + .begin_upload("ubuntu.iso") + .await + .expect("stale .partial is reclaimed, not rejected"); + assert_eq!(handle.id, "ubuntu"); + // Reclaimed: the leftover bytes are gone (fresh, empty file). + let meta = tokio::fs::metadata(&partial).await.unwrap(); + assert_eq!(meta.len(), 0, "stale .partial must be truncated on reclaim"); + } + + #[tokio::test] + async fn begin_upload_still_rejects_completed_iso() { + // A finished upload (final .iso on disk) is a genuine duplicate, not + // an abandoned attempt — that case must still be refused. + let dir = tempdir().unwrap(); + let store = IsoStore::new(dir.path().to_path_buf()); + store.ensure_dirs().await.unwrap(); + tokio::fs::write(dir.path().join("rocky.iso"), b"done") .await .unwrap(); - let r = store.begin_upload("ubuntu.iso").await; + let r = store.begin_upload("rocky.iso").await; assert!(matches!(r, Err(Error::Invalid(_)))); } diff --git a/crates/openpxe/Cargo.toml b/crates/openpxe/Cargo.toml index 4c74208..3058941 100644 --- a/crates/openpxe/Cargo.toml +++ b/crates/openpxe/Cargo.toml @@ -26,7 +26,5 @@ tracing.workspace = true tracing-subscriber.workspace = true anyhow.workspace = true clap.workspace = true -serde.workspace = true -toml.workspace = true bytes.workspace = true time.workspace = true diff --git a/crates/openpxe/src/main.rs b/crates/openpxe/src/main.rs index 368258c..574d31f 100644 --- a/crates/openpxe/src/main.rs +++ b/crates/openpxe/src/main.rs @@ -122,6 +122,7 @@ async fn main() -> anyhow::Result<()> { 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 notify = openpxe_core::NotifyStore::load_or_default(&config.paths.work_dir); + let api_key = openpxe_core::ApiKeyStore::load_or_init(&config.paths.work_dir); let sessions = openpxe_http_api::auth::SessionStore::default(); let metrics = Metrics::new(); @@ -201,6 +202,7 @@ async fn main() -> anyhow::Result<()> { pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(), admin: admin.clone(), sessions: sessions.clone(), + api_key, sso: sso.clone(), saml: openpxe_http_api::saml_routes::SamlRuntime::default(), notify: notify.clone(), diff --git a/crates/tftp/Cargo.toml b/crates/tftp/Cargo.toml index a5eaa80..dbb8f22 100644 --- a/crates/tftp/Cargo.toml +++ b/crates/tftp/Cargo.toml @@ -15,6 +15,4 @@ openpxe-ipxe-assets.workspace = true tokio.workspace = true socket2.workspace = true tracing.workspace = true -thiserror.workspace = true anyhow.workspace = true -bytes.workspace = true diff --git a/crates/webui/src/app.js b/crates/webui/src/app.js index 38abe09..a928508 100644 --- a/crates/webui/src/app.js +++ b/crates/webui/src/app.js @@ -603,36 +603,85 @@ // ── Upload card ── const drop = el('div', {class:'drop', id:'drop'}, [ - el('div', {}, ['Drop an ', el('strong', {}, '.iso'), ' here, or click to choose.']), + el('div', {}, ['Drop one or more ', el('strong', {}, '.iso'), ' files here, or click to choose.']), el('div', {style:'font-size:12px;margin-top:6px'}, - 'Linux + Windows installers auto-detected on upload. Streaming, no 502s on big files.'), + 'Linux + Windows installers auto-detected on upload. Multiple files upload at once. Streaming, no 502s on big files.'), ]); const file = el('input', {type:'file', accept:'.iso,application/octet-stream', - style:'display:none', id:'file'}); - const prog = el('div', {class:'progress', id:'prog'}, el('div', {class:'bar', id:'bar'})); - const upMsg = el('div', {class:'msg', id:'upmsg'}); - // v0.5.8: cancel button — shown only while an upload is in flight. - const cancelUpload = el('button', {class:'danger', type:'button', - style:'display:none;margin-top:12px', id:'cancel-upload'}, 'Cancel upload'); + multiple:true, style:'display:none', id:'file'}); + // v0.8.0: one progress row per file, appended here. Replaces the + // single shared bar/msg/cancel that a second concurrent upload used + // to clobber. + const uploadsList = el('div', {id:'uploads', style:'display:grid;gap:12px'}); + + // One page-leave guard + one tab-hide cleanup for the whole card, + // registered only while ≥1 upload is in flight (added on 0→1, removed + // on 1→0) so listeners never pile up across re-renders. + let activeUploads = 0; + const activeIds = new Set(); + const warnLeave = (e) => { if (activeUploads > 0) { e.preventDefault(); e.returnValue = ''; return ''; } }; + const abortOnHide = () => { + // keepalive lets these DELETEs outlive the unload; the server also + // reclaims an orphaned .partial on the next upload, so best-effort + // is fine here. + for (const id of activeIds) { + try { fetch('/api/uploads/' + encodeURIComponent(id), {method:'DELETE', keepalive:true}); } catch (_) {} + } + }; + const addGuards = () => { + window.addEventListener('beforeunload', warnLeave); + window.addEventListener('pagehide', abortOnHide); + }; + const removeGuards = () => { + window.removeEventListener('beforeunload', warnLeave); + window.removeEventListener('pagehide', abortOnHide); + }; + + const failText = async (r) => { + const text = (await r.text()).slice(0, 240); + let hint = ''; + if (r.status === 413) hint = ' - body too large. A proxy likely rejected this chunk.'; + else if (r.status === 502) hint = ' - bad gateway. Proxy lost the upstream mid-stream.'; + else if (r.status === 504) hint = ' - gateway timeout. Try the LAN IP directly.'; + else if (r.status === 409) hint = ' - name conflict or offset mismatch. Remove the old ISO and retry.'; + return 'HTTP ' + r.status + ' ' + text + hint; + }; + + // Launch an upload per dropped/selected .iso. The browser's ~6 + // connections-per-origin cap naturally bounds how many stream at + // once, so there's no hand-rolled queue. Non-.iso files are ignored. + const startMany = (fileList) => { + [...fileList].filter(f => /\.iso$/i.test(f.name)).forEach(uploadOne); + }; drop.onclick = () => file.click(); drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('hover'); }); drop.addEventListener('dragleave', () => drop.classList.remove('hover')); drop.addEventListener('drop', e => { e.preventDefault(); drop.classList.remove('hover'); - if (e.dataTransfer.files[0]) upload(e.dataTransfer.files[0]); + startMany(e.dataTransfer.files); }); - file.onchange = () => { if (file.files[0]) upload(file.files[0]); }; + // Reset value so re-selecting the same filename still fires onchange. + file.onchange = () => { startMany(file.files); file.value = ''; }; - // Chunked upload telemetry. The old browser path posted one huge - // multipart body, which left operators staring at 0% when a reverse - // proxy buffered or rejected the request before OpenPXE saw it. This - // path writes small raw chunks; each acknowledged chunk advances the - // bar and leaves a visible .partial file in the ISO directory. - async function upload(f) { + // One independent chunked upload with its own progress row. The old + // browser path posted one huge multipart body, which left operators + // staring at 0% when a reverse proxy buffered or rejected the request + // before OpenPXE saw it. This path writes small raw chunks; each + // acknowledged chunk advances the bar and leaves a visible .partial. + async function uploadOne(f) { const started = Date.now(); - const bar = $('#bar'); - const setStatus = (text, cls) => { upMsg.textContent = text; upMsg.className = 'msg ' + (cls || ''); }; + const bar = el('div', {class:'bar'}); + const prog = el('div', {class:'progress active'}, bar); + const rowMsg = el('div', {class:'msg'}); + const cancelBtn = el('button', {class:'danger', type:'button', style:'margin-top:8px'}, 'Cancel'); + const row = el('div', {}, [ + el('div', {style:'font-weight:600;font-size:13px;margin-bottom:6px;word-break:break-all'}, f.name), + prog, rowMsg, cancelBtn, + ]); + uploadsList.appendChild(row); + + const setStatus = (text, cls) => { rowMsg.textContent = text; rowMsg.className = 'msg ' + (cls || ''); }; const update = (loaded, total, phase) => { const pct = total > 0 ? Math.min(100, (loaded / total) * 100) : 100; bar.style.width = pct.toFixed(1) + '%'; @@ -640,34 +689,24 @@ const rate = loaded > 0 ? loaded / elapsed : 0; const remain = rate > 0 ? (total - loaded) / rate : 0; setStatus( - phase + ' ' + f.name + ' - ' + + phase + ' - ' + fmtBytes(loaded) + ' of ' + fmtBytes(total) + ' (' + pct.toFixed(1) + '%, ' + fmtBytes(rate) + '/s' + (remain > 0 ? ', ' + Math.ceil(remain) + 's left' : '') + ')'); }; - const failText = async (r) => { - const text = (await r.text()).slice(0, 240); - let hint = ''; - if (r.status === 413) hint = ' - body too large. A proxy likely rejected this chunk.'; - else if (r.status === 502) hint = ' - bad gateway. Proxy lost the upstream mid-stream.'; - else if (r.status === 504) hint = ' - gateway timeout. Try the LAN IP directly.'; - else if (r.status === 409) hint = ' - name conflict or offset mismatch. Remove the old ISO and retry.'; - return 'HTTP ' + r.status + ' ' + text + hint; - }; let uploadId = null; - // v0.5.8: cancel + leave-page guard. The AbortController stops the - // in-flight chunk; the beforeunload listener warns the operator - // that navigating away aborts the upload (the server-side partial - // is then cleaned up by the DELETE in the catch below). + // The AbortController stops this upload's in-flight chunk on Cancel. + // The card-level beforeunload guard (added while activeUploads > 0) + // warns on navigation; the server reclaims an abandoned .partial on + // the next upload either way. const ac = new AbortController(); let canceled = false; - const warnLeave = (e) => { e.preventDefault(); e.returnValue = ''; return ''; }; - window.addEventListener('beforeunload', warnLeave); - cancelUpload.style.display = ''; - cancelUpload.onclick = () => { canceled = true; ac.abort(); }; - setStatus('Preparing upload for ' + f.name + ' (' + fmtBytes(f.size) + ')'); - prog.classList.add('active'); + cancelBtn.onclick = () => { canceled = true; ac.abort(); }; + + activeUploads += 1; + if (activeUploads === 1) addGuards(); + setStatus('Preparing ' + f.name + ' (' + fmtBytes(f.size) + ')'); bar.style.width = '1%'; try { @@ -678,6 +717,7 @@ if (!begin.ok) throw new Error(await failText(begin)); const session = await begin.json(); uploadId = session.upload_id; + activeIds.add(uploadId); const chunkSize = Math.max(1024 * 1024, Number(session.chunk_size || 8 * 1024 * 1024)); let offset = Number(session.offset || 0); @@ -702,23 +742,29 @@ } while (!finished); setStatus('Uploaded and analyzed: ' + f.name + ' (' + fmtBytes(f.size) + ')', 'ok'); - render('storage'); } catch (err) { if (uploadId) { try { await fetch('/api/uploads/' + encodeURIComponent(uploadId), {method: 'DELETE'}); } - catch {} + catch (_) {} } if (canceled || (err && err.name === 'AbortError')) { - setStatus('Upload canceled — partial file discarded.', ''); + setStatus('Canceled — partial file discarded.', ''); } else { setStatus('Upload failed: ' + (err && err.message ? err.message : String(err)), 'err'); } } finally { - window.removeEventListener('beforeunload', warnLeave); - cancelUpload.style.display = 'none'; - cancelUpload.onclick = null; + if (uploadId) activeIds.delete(uploadId); + cancelBtn.style.display = 'none'; prog.classList.remove('active'); - if (!upMsg.className.includes('ok')) bar.style.width = '0'; + activeUploads -= 1; + if (activeUploads === 0) { + removeGuards(); + // Refresh the table to show the new image(s) — but only if the + // operator is still on Storage. isConnected goes false once + // render() swapped the view, so a mid-upload tab change won't + // yank them back here. + if (uploadsList.isConnected) render('storage'); + } } } @@ -1355,7 +1401,7 @@ diskCard, el('div', {class:'card'}, [ el('header', {}, el('h2', {}, 'Upload ISO')), - el('div', {class:'body'}, [drop, file, prog, upMsg, cancelUpload]), + el('div', {class:'body'}, [drop, file, uploadsList]), ]), // v0.5.1: SMB + NFS unified into one "Remote shares" card with a // protocol dropdown. Backend endpoints are unchanged; this is a @@ -1732,7 +1778,7 @@ }, settings: async () => { - const [status, me, sso, notify, docs] = await Promise.all([ + const [status, me, sso, notify, docs, apiKey] = await Promise.all([ getJSON('/api/status'), getJSON('/api/me').catch(() => ({})), getJSON('/api/sso').catch(() => ({ @@ -1742,6 +1788,7 @@ // fetches the notify config + API docs it needs too. getJSON('/api/notify').catch(() => ({ enabled:false, kind:'slack' })), getJSON('/api/docs').catch(() => ({ groups: [] })), + getJSON('/api/api-key').catch(() => ({ key:'', header:'x-api-key' })), ]); // ── Account card (Forms admin credentials, v0.4.5). @@ -2055,7 +2102,7 @@ // into a collapsible disclosure beneath the core settings cards — // webhook/email notifications + the API reference. Keeps Settings // clean by default while leaving the knobs one click away. - const [notifyCard, apiCard] = views._advancedCards(notify, docs); + const [notifyCard, apiCard] = views._advancedCards(notify, docs, apiKey); const advanced = el('details', {class:'advanced-disclosure', style:'margin-top:18px'}, [ el('summary', {class:'advanced-summary'}, 'Advanced'), el('div', {class:'grid', style:'margin-top:14px'}, [notifyCard, apiCard]), @@ -2070,7 +2117,7 @@ // and the API reference. There is no longer an Advanced sidebar tab; // the Settings view folds these into a collapsible disclosure and // passes in the pre-fetched `notify` + `docs` payloads. - _advancedCards: (notify, docs) => { + _advancedCards: (notify, docs, apiKey) => { // ── Notification config ── const nMsg = el('div', {class:'msg', style:'margin-top:12px'}); @@ -2185,14 +2232,47 @@ ]), ]); - // ── API reference (relocated from Settings) ── + // ── API key + reference (relocated from Settings) ── const groups = docs.groups || []; + + // v0.8.0: operator API key. Paste into the `x-api-key` request + // header to drive /api/* from Postman / scripts without a browser + // session (full operator access). Read + rotate via /api/api-key. + const keyHeader = (apiKey && apiKey.header) || 'x-api-key'; + const keyField = el('input', {type:'text', readonly:true, + value: (apiKey && apiKey.key) || '(unavailable)', + style:'width:100%;font-family:var(--mono)'}); + const keyMsg = el('span', {class:'hint', style:'margin-left:10px'}); + const copyKey = el('button', {class:'ghost', type:'button', onclick: async () => { + try { await navigator.clipboard.writeText(keyField.value); keyMsg.textContent = 'Copied to clipboard.'; } + catch { keyField.select(); keyMsg.textContent = 'Select the field and copy.'; } + }}, 'Copy'); + const regenKey = el('button', {class:'danger', type:'button', style:'margin-left:8px', + onclick: async () => { + if (!confirm('Regenerate the API key? The current key stops working immediately and any client using it must be updated.')) return; + const r = await postJSON('/api/api-key/regenerate', {}); + if (r.ok) { const j = await r.json(); keyField.value = j.key || ''; keyMsg.textContent = 'New key generated.'; } + else { keyMsg.textContent = 'Regenerate failed: ' + (await r.text()).slice(0, 120); } + }}, 'Regenerate'); + const apiKeyBlock = el('div', {style:'padding:16px;border-bottom:1px solid var(--border)'}, [ + el('label', {class:'field', style:'margin-bottom:10px'}, [ + el('span', {class:'name'}, 'API key'), + keyField, + el('span', {class:'hint'}, [ + 'Send as the ', el('code', {}, keyHeader), + ' request header to call the API from Postman or scripts — full operator access, so keep it secret.', + ]), + ]), + el('div', {}, [copyKey, regenKey, keyMsg]), + ]); + const apiCard = el('div', {class:'card'}, [ el('header', {}, [ - el('h2', {}, 'API reference'), + el('h2', {}, 'API'), el('span', {class:'sub'}, groups.reduce((n, g) => n + (g.endpoints || []).length, 0) + ' endpoints'), ]), + apiKeyBlock, el('div', {class:'api-ref'}, groups.length ? groups.map(g => el('div', {class:'group'}, [