From 9c6903351f2bac9843c65cd6ac152f02212df37b Mon Sep 17 00:00:00 2001 From: Miles Ward Date: Wed, 6 May 2026 22:35:11 -0400 Subject: [PATCH] v0.3.1: per-ISO boot password gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators can now lock individual ISOs behind a password set in the WebUI. Picking a locked image at the PXE menu prompts the operator on the client console; the boot script is only released after a correct match. The plaintext never leaves the request — server stores bcrypt hashes, scripts never echo the candidate. ## Backend - New optional `password_hash: Option` on `IsoMeta`. Skipped during serialize when None, so existing meta.json files don't grow a noisy `null` field. - `IsoStore::set_password(id, Some("pw"))` hashes via bcrypt `DEFAULT_COST` (10 — fast enough for an interactive iPXE prompt, expensive enough to be hostile to brute force on a leaked meta.json). `set_password(id, None)` and `set_password(id, Some(""))` both clear. - `IsoStore::verify_password` returns Ok(true) when no password is set, so the gate stays open for the common case. - `IsoMeta::is_password_protected()` predicate the HTTP layer + UI share. - NFS-sourced ISOs persist their hash in memory only — the share is the source of truth for those, and it doesn't carry hash sidecars. ## HTTP API - `PUT /api/isos/:id/password` body `{ "password": "..." }` to set, `{ "password": null }` (or empty string) to clear. - `DELETE /api/isos/:id/password` for the explicit clear. - Both 204 on success, 404 for unknown ids. - `/boot/.ipxe` now intercepts: - no `?token=` -> render password-prompt script - `?token=` -> render auth-fail script (sleeps 2s, chains back to the entry which re-prompts) - `?token=` -> render the real boot script - ISO without password ignores token entirely (per-MAC bookmarks still work without changes). ## iPXE prompt `render_password_prompt`: - `set password ` then `read --secret password` — accepts input without echoing. - Empty input chains back to the main menu (lets the operator back out of a misclick). - Submit chains `?token=${password:uristring}`. The `:uristring` modifier URL-encodes the value, so passwords with `&`, `?`, `=`, spaces, etc. survive transport. `render_password_failed`: - Single line saying so + 2s sleep, then re-chains the entry. - Server-side WARN log records the entry id only, never the candidate value (verified in smoke test). ## UI Storage tab's image table grows an `Auth` column showing `protected` / `open`, plus a 🔒 next to the filename when locked. Per-row "Set password" / "Password ✎" button toggles an inline editor in the next table row containing: - a "Password protect this image" checkbox - a `` (hidden when the checkbox is off) - a Save button Save calls PUT or DELETE on `/api/isos/:id/password` based on the checkbox state and clears the input field before re-rendering, so the plaintext doesn't sit in the DOM longer than needed. ## Menu indicator `render_family_menu` adds a `*` prefix immediately before the size box on protected entries — ASCII only because some firmware menu consoles mangle non-ASCII glyphs. Looks like: item --key 1 win11_test-winpe *[ 5234 MB] Windows 11 Test ISO ## Tests 74 passing across the workspace (was 66 in v0.3.0): - 3 new store unit tests (bcrypt round-trip, unknown-id error, meta.json persistence across restart) - 2 new ipxe_script unit tests (prompt/auth-fail invariants: read --secret, uristring, no candidate echo) - 3 new HTTP integration tests (full gate flow upload-set-prompt- fail-success-clear, null/empty bodies, 404 on unknown id) cargo clippy --workspace --all-targets clean. Local smoke verified upload + lock + prompt + auth-fail + correct + menu indicator + log scrub on a real release binary. ## Operational notes - HTTP, not HTTPS — token rides in the query string. Acceptable on a trusted boot VLAN; do NOT expose OpenPXE to untrusted networks with this feature relied on for security. Reverse-proxy in front of OpenPXE will end up with the token in access logs. - bcrypt cost is `DEFAULT_COST` (10). One verify takes ~50ms on modern x86, which is the worst-case latency added to a correct boot. Tunable via the bcrypt crate if needed. --- Cargo.toml | 3 +- crates/http-api/src/app.rs | 118 ++++++++++++++++++++- crates/http-api/src/ipxe_script.rs | 92 +++++++++++++++- crates/http-api/tests/full_flow.rs | 130 +++++++++++++++++++++++ crates/iso-store/Cargo.toml | 1 + crates/iso-store/src/store.rs | 162 +++++++++++++++++++++++++++++ crates/webui/src/app.js | 112 ++++++++++++++++++-- crates/webui/src/index.html | 2 +- 8 files changed, 608 insertions(+), 12 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8ebd1bc..44f66b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.3.0" +version = "0.3.1" edition = "2021" rust-version = "1.80" license = "MIT OR Apache-2.0" @@ -53,6 +53,7 @@ uuid = { version = "1.10", features = ["v4", "serde"] } time = { version = "0.3", features = ["serde", "serde-human-readable", "formatting", "macros"] } sha2 = "0.10" hex = "0.4" +bcrypt = "0.15" once_cell = "1.19" parking_lot = "0.12" rust-embed = { version = "8.5", features = ["include-exclude"] } diff --git a/crates/http-api/src/app.rs b/crates/http-api/src/app.rs index b5816b1..e20688a 100644 --- a/crates/http-api/src/app.rs +++ b/crates/http-api/src/app.rs @@ -61,6 +61,12 @@ pub fn build_router(state: AppState) -> Router { // JSON API. .route("/api/isos", get(api_list_isos).post(api_upload_iso)) .route("/api/isos/:id", delete(api_delete_iso)) + // v0.3.1: per-ISO password gate. PUT body `{ "password": "..." }` + // sets, `{ "password": null }` (or DELETE) clears. + .route( + "/api/isos/:id/password", + axum::routing::put(api_set_iso_password).delete(api_clear_iso_password), + ) .route("/api/clients", get(api_list_clients)) .route("/api/status", get(api_status)) .route("/api/settings", get(api_get_settings).put(api_put_settings)) @@ -185,9 +191,19 @@ struct BootMenuParams { mac: Option, } +#[derive(Debug, Deserialize)] +struct BootSubParams { + /// iPXE-supplied password token. Sent by the prompt script as + /// `?token=${password:uristring}` so special chars survive URL + /// encoding. Absent on the first request — that's how we know the + /// client hasn't been prompted yet. + token: Option, +} + async fn boot_sub( State(state): State, AxumPath(filename): AxumPath, + Query(p): Query, ) -> Response { // `/boot/.ipxe` where `` is either one of our reserved // submenu names (prefixed `_`) or a boot entry id. @@ -203,11 +219,56 @@ async fn boot_sub( "_util" => render_util(base), "_shell" => render_shell(base), "_nic" => render_nic_info(base), - "_queue" => render_queue_entry(base), + "_queue" => render_queue_entry(base), other => { for iso in &isos { for entry in &iso.boot_entries { if entry.id == other { + // Password gate. If the ISO has a password set + // we block the actual boot script behind it: + // - no token -> render a prompt + // - wrong token -> render auth-fail + // - correct token -> serve the boot script + // ISO without a password ignores the token + // entirely, so per-MAC bookmarks stay simple. + if iso.is_password_protected() { + match p.token.as_deref() { + None | Some("") => { + return text_plain(crate::ipxe_script::render_password_prompt( + &entry.id, &iso.filename, base, + )); + } + Some(token) => match state.iso_store.verify_password(&iso.id, token) { + Ok(true) => { /* fall through to render the entry */ } + Ok(false) => { + // Don't log the candidate — just the + // mac (when iPXE supplies one) and + // the entry id, so an operator can + // see brute-force attempts in the + // live log. + tracing::warn!( + target: "openpxe::http::boot", + entry = %other, + "wrong password supplied for protected boot entry" + ); + return text_plain( + crate::ipxe_script::render_password_failed( + &entry.id, base, + ), + ); + } + Err(e) => { + tracing::error!( + target: "openpxe::http::boot", + entry = %other, error = %e, + "password verify failed unexpectedly" + ); + return (StatusCode::INTERNAL_SERVER_ERROR, + "password check failed").into_response(); + } + }, + } + } return text_plain(render_entry(entry, &settings, base)); } } @@ -349,6 +410,61 @@ async fn api_delete_iso( } } +#[derive(Debug, Deserialize)] +struct SetPasswordBody { + /// Plaintext password. `null` or empty/whitespace clears the + /// password (same as a DELETE on this resource). The server hashes + /// with bcrypt before persisting; the plaintext is never stored. + password: Option, +} + +async fn api_set_iso_password( + State(state): State, + AxumPath(id): AxumPath, + Json(body): Json, +) -> Response { + match state + .iso_store + .set_password(&id, body.password.as_deref()) + .await + { + Ok(()) => { + let now_protected = state + .iso_store + .get(&id) + .is_some_and(|m| m.is_password_protected()); + // We deliberately do not log the password value, only + // whether the ISO ended up protected. + tracing::info!( + target: "openpxe::http::iso", + iso = %id, protected = now_protected, + "iso password updated" + ); + StatusCode::NO_CONTENT.into_response() + } + Err(pxeforge_error_invalid) if matches!(pxeforge_error_invalid, openpxe_core::Error::Invalid(_)) => { + (StatusCode::NOT_FOUND, format!("{pxeforge_error_invalid}")).into_response() + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), + } +} + +async fn api_clear_iso_password( + State(state): State, + AxumPath(id): AxumPath, +) -> Response { + match state.iso_store.set_password(&id, None).await { + Ok(()) => { + tracing::info!( + target: "openpxe::http::iso", + iso = %id, "iso password cleared" + ); + StatusCode::NO_CONTENT.into_response() + } + Err(e) => (StatusCode::NOT_FOUND, format!("{e}")).into_response(), + } +} + async fn api_upload_iso( State(state): State, mut multipart: Multipart, diff --git a/crates/http-api/src/ipxe_script.rs b/crates/http-api/src/ipxe_script.rs index f2c2446..1860471 100644 --- a/crates/http-api/src/ipxe_script.rs +++ b/crates/http-api/src/ipxe_script.rs @@ -110,10 +110,15 @@ pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) -> for entry in &iso.boot_entries { let size_label = fmt_size_mib(iso.size_bytes); let key = hotkey_for_index(count); + // Visual hint: a leading `*` marks password-protected entries. + // ASCII only — iPXE's menu console mangles non-ASCII on some + // firmwares. + let lock = if iso.is_password_protected() { "*" } else { " " }; let _ = writeln!( - s, "item {}{} [{:>6}] {}", + s, "item {}{} {}[{:>6}] {}", key, entry.id, + lock, size_label, escape_label(&entry.title), ); @@ -325,3 +330,88 @@ fn has_family(isos: &[IsoMeta], pred: fn(DistroFamily) -> bool) -> bool { fn escape_label(s: &str) -> String { s.chars().map(|c| match c { '\n' | '\r' => ' ', c => c }).collect() } + +/// Render the password-prompt script for a protected boot entry. +/// +/// Flow on the client: +/// 1. iPXE clears any leftover ${password}, prints a banner naming the +/// ISO so the operator knows what they're being asked for. +/// 2. `read --secret password` accepts input without echoing it to +/// the screen. +/// 3. An empty input bails back to the main menu (lets the operator +/// back out of a misclick). +/// 4. Otherwise the script chains the same /boot/.ipxe URL but +/// with `?token=${password:uristring}`. iPXE's `:uristring` +/// modifier URL-encodes the value so `&`, `?`, `=`, spaces, etc. +/// survive transport. +/// 5. The server replies with either the boot script (correct +/// password) or [`render_password_failed`] (wrong password). On +/// transport failure we fall back to the main menu. +#[must_use] +pub fn render_password_prompt(entry_id: &str, iso_filename: &str, base_url: &str) -> String { + let base = base_url.trim_end_matches('/'); + let label = escape_label(iso_filename); + let mut s = String::new(); + let _ = writeln!(s, "#!ipxe"); + let _ = writeln!(s, "# OpenPXE password prompt for {label}"); + let _ = writeln!(s, "echo"); + let _ = writeln!(s, "echo =========================================="); + let _ = writeln!(s, "echo This image requires a password"); + let _ = writeln!(s, "echo {label}"); + let _ = writeln!(s, "echo (enter alone returns to main menu)"); + let _ = writeln!(s, "echo =========================================="); + let _ = writeln!(s, "set password "); + let _ = writeln!(s, "read --secret password"); + let _ = writeln!(s, "iseq ${{password}} \"\" && chain {base}/boot.ipxe || goto submit"); + let _ = writeln!(s, ":submit"); + let _ = writeln!(s, "echo Verifying..."); + let _ = writeln!( + s, + "chain {base}/boot/{entry_id}.ipxe?token=${{password:uristring}} || chain {base}/boot.ipxe" + ); + s +} + +/// Render the "wrong password" script. Tells the operator, sleeps for +/// two seconds (gives the eye time to register the message and dampens +/// brute-force rate without help from the server), and chains back to +/// the same entry — which sends them through the prompt flow again. +#[must_use] +pub fn render_password_failed(entry_id: &str, base_url: &str) -> String { + let base = base_url.trim_end_matches('/'); + let mut s = String::new(); + let _ = writeln!(s, "#!ipxe"); + let _ = writeln!(s, "echo"); + let _ = writeln!(s, "echo Wrong password."); + let _ = writeln!(s, "sleep 2"); + let _ = writeln!(s, "chain {base}/boot/{entry_id}.ipxe || chain {base}/boot.ipxe"); + s +} + +#[cfg(test)] +mod password_tests { + use super::*; + + #[test] + fn prompt_uses_secret_read_and_uri_escape() { + let s = render_password_prompt("alpha-linux", "Alpha Test.iso", "http://10.0.0.5"); + assert!(s.starts_with("#!ipxe\n")); + assert!(s.contains("read --secret password")); + assert!(s.contains("Alpha Test.iso")); + // URI-string modifier on the var so passwords with `&`/spaces survive. + assert!(s.contains("token=${password:uristring}")); + // Empty enter sends back to the main menu, not back into the prompt + // (avoids a wedged client if the operator chose by mistake). + assert!(s.contains("&& chain http://10.0.0.5/boot.ipxe || goto submit")); + // Never log/echo the value. + assert!(!s.contains("echo ${password")); + } + + #[test] + fn failed_chains_back_to_entry() { + let s = render_password_failed("alpha-linux", "http://10.0.0.5"); + assert!(s.contains("Wrong password.")); + // Re-target the entry so the prompt flow runs again. + assert!(s.contains("chain http://10.0.0.5/boot/alpha-linux.ipxe")); + } +} diff --git a/crates/http-api/tests/full_flow.rs b/crates/http-api/tests/full_flow.rs index 076aaa0..354f1d5 100644 --- a/crates/http-api/tests/full_flow.rs +++ b/crates/http-api/tests/full_flow.rs @@ -672,3 +672,133 @@ async fn network_endpoint_exposes_dns_round_trip() { let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); assert_eq!(v["dns_server"], "10.0.0.1"); } + +// ─── v0.3.1: per-ISO password gate ──────────────────────────────────────── + +#[tokio::test] +async fn iso_password_gate_blocks_until_correct_token() { + let (state, _dir) = build_state().await; + let app = build_router(state); + + // Upload a synthetic Alpine ISO so we have a real boot entry id to + // protect. Upload filename "fake-alpine.iso" -> id "fake-alpine", + // boot entry id "fake-alpine-linux". + let iso = fake_alpine_iso(); + let (ct, body) = multipart_iso_body("fake-alpine.iso", &iso); + let res = app + .clone() + .oneshot( + Request::builder() + .method("POST").uri("/api/isos") + .header("content-type", ct) + .body(Body::from(body)).unwrap()).await.unwrap(); + assert_eq!(res.status(), StatusCode::CREATED); + + // 1. With NO password set, /boot/.ipxe returns the boot script + // immediately and the lock indicator is NOT in the menu. + let (_, body) = get(&app, "/boot/fake-alpine-linux.ipxe").await; + let s = String::from_utf8(body).unwrap(); + assert!(s.contains("kernel "), "expected boot script, got:\n{s}"); + let (_, lm) = get(&app, "/boot/_linux_menu.ipxe").await; + let lm = String::from_utf8(lm).unwrap(); + assert!(lm.contains("fake-alpine-linux")); + assert!(!lm.contains("fake-alpine-linux *["), + "expected no lock marker in menu before password set:\n{lm}"); + + // 2. Set a password. + let res = app.clone().oneshot( + Request::builder() + .method("PUT") + .uri("/api/isos/fake-alpine/password") + .header("content-type", "application/json") + .body(Body::from(r#"{"password":"hunter2"}"#)).unwrap() + ).await.unwrap(); + assert_eq!(res.status(), StatusCode::NO_CONTENT); + + // The menu now shows the lock marker (`*` prefix on the size box). + let (_, lm) = get(&app, "/boot/_linux_menu.ipxe").await; + let lm = String::from_utf8(lm).unwrap(); + assert!(lm.contains("fake-alpine-linux *["), + "expected lock marker in menu after password set:\n{lm}"); + + // 3. Without a token, /boot/.ipxe now returns the password + // PROMPT script (read --secret), not the boot script. + let (_, body) = get(&app, "/boot/fake-alpine-linux.ipxe").await; + let s = String::from_utf8(body).unwrap(); + assert!(s.contains("read --secret password"), + "expected prompt script with no token, got:\n{s}"); + assert!(!s.contains("kernel "), "should not include kernel line yet"); + + // 4. Wrong token -> "Wrong password." script that chains back to the entry. + let (_, body) = get(&app, "/boot/fake-alpine-linux.ipxe?token=wrongpw").await; + let s = String::from_utf8(body).unwrap(); + assert!(s.contains("Wrong password."), "expected auth-fail script, got:\n{s}"); + assert!(s.contains("/boot/fake-alpine-linux.ipxe")); + assert!(!s.contains("kernel ")); + // Critical: the WRONG token must NEVER be echoed back in the script. + assert!(!s.contains("wrongpw"), "wrong token must not appear in response"); + + // 5. Correct token -> real boot script. + let (_, body) = get(&app, "/boot/fake-alpine-linux.ipxe?token=hunter2").await; + let s = String::from_utf8(body).unwrap(); + assert!(s.contains("kernel "), "expected boot script with correct token, got:\n{s}"); + // Don't echo the password into the boot script either. + assert!(!s.contains("hunter2"), "correct password must not leak into boot script"); + + // 6. Clear the password (DELETE). + let res = app.clone().oneshot( + Request::builder() + .method("DELETE") + .uri("/api/isos/fake-alpine/password") + .body(Body::empty()).unwrap() + ).await.unwrap(); + assert_eq!(res.status(), StatusCode::NO_CONTENT); + + // Boot is open again, no lock indicator. + let (_, body) = get(&app, "/boot/fake-alpine-linux.ipxe").await; + let s = String::from_utf8(body).unwrap(); + assert!(s.contains("kernel "), "expected boot script after clear, got:\n{s}"); + let (_, lm) = get(&app, "/boot/_linux_menu.ipxe").await; + let lm = String::from_utf8(lm).unwrap(); + assert!(!lm.contains("fake-alpine-linux *[")); +} + +#[tokio::test] +async fn iso_password_set_then_clear_via_null_body() { + let (state, _dir) = build_state().await; + let app = build_router(state); + + // Upload + set + clear via `{"password": null}` (alternative to DELETE). + let iso = fake_alpine_iso(); + let (ct, body) = multipart_iso_body("fake-alpine.iso", &iso); + let res = app.clone().oneshot( + Request::builder().method("POST").uri("/api/isos") + .header("content-type", ct) + .body(Body::from(body)).unwrap()).await.unwrap(); + assert_eq!(res.status(), StatusCode::CREATED); + + for body in [r#"{"password":"x"}"#, r#"{"password":null}"#, r#"{"password":""}"#] { + let res = app.clone().oneshot( + Request::builder().method("PUT") + .uri("/api/isos/fake-alpine/password") + .header("content-type", "application/json") + .body(Body::from(body.to_string())).unwrap()).await.unwrap(); + assert_eq!(res.status(), StatusCode::NO_CONTENT, "body={body}"); + } + // After the empty string, the entry should be unprotected. + let (_, b) = get(&app, "/boot/fake-alpine-linux.ipxe").await; + let s = String::from_utf8(b).unwrap(); + assert!(s.contains("kernel "), "should be unprotected after empty pw, got:\n{s}"); +} + +#[tokio::test] +async fn set_password_for_unknown_iso_returns_404() { + let (state, _dir) = build_state().await; + let app = build_router(state); + let res = app.clone().oneshot( + Request::builder().method("PUT") + .uri("/api/isos/does-not-exist/password") + .header("content-type", "application/json") + .body(Body::from(r#"{"password":"x"}"#)).unwrap()).await.unwrap(); + assert_eq!(res.status(), StatusCode::NOT_FOUND); +} diff --git a/crates/iso-store/Cargo.toml b/crates/iso-store/Cargo.toml index 8e22e93..a608304 100644 --- a/crates/iso-store/Cargo.toml +++ b/crates/iso-store/Cargo.toml @@ -20,6 +20,7 @@ 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 diff --git a/crates/iso-store/src/store.rs b/crates/iso-store/src/store.rs index f77a167..a87ce36 100644 --- a/crates/iso-store/src/store.rs +++ b/crates/iso-store/src/store.rs @@ -48,6 +48,25 @@ pub struct IsoMeta { /// Old `meta.json` files without this field deserialize as `Local`. #[serde(default)] pub source: IsoSource, + /// Optional bcrypt hash of an operator-set password. When present, + /// `/boot/.ipxe` returns a `read --secret` prompt instead of + /// the boot script until the client chains back with the correct + /// `?token=...`. We never store, log, or transmit the plaintext. + /// Skipped on serialize when None to keep meta.json clean for + /// the common no-password case. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password_hash: Option, +} + +impl IsoMeta { + /// Convenience predicate the HTTP layer + UI can both use. + #[must_use] + pub fn is_password_protected(&self) -> bool { + self.password_hash + .as_deref() + .map(str::trim) + .is_some_and(|h| !h.is_empty()) + } } pub struct UploadHandle { @@ -94,6 +113,7 @@ impl UploadHandle { introspection, boot_entries, source: IsoSource::Local, + password_hash: None, }; store.persist_meta(&meta).await?; store.insert(meta.clone()); @@ -289,6 +309,7 @@ impl IsoStore { introspection, boot_entries, source, + password_hash: None, }; self.inner.write().isos.insert(id, meta); } @@ -302,6 +323,66 @@ impl IsoStore { !matches!(&m.source, IsoSource::Nfs { mount_id: mid, .. } if mid == mount_id) }); } + + /// Set or clear an ISO's boot password. + /// + /// `Some("plaintext")` hashes via bcrypt (cost 10 — fast enough for + /// an interactive iPXE prompt, slow enough to be hostile to brute + /// force on a leaked meta.json) and persists. + /// + /// `None` removes the password — the next /boot/.ipxe request + /// returns the script directly without a prompt. + /// + /// We never store, log, or transmit the plaintext. + pub async fn set_password(&self, id: &str, password: Option<&str>) -> Result<()> { + let new_hash = match password { + None => None, + Some(pw) => { + let pw = pw.trim(); + if pw.is_empty() { + None + } else { + let h = bcrypt::hash(pw, bcrypt::DEFAULT_COST) + .map_err(|e| Error::Other(e.into()))?; + Some(h) + } + } + }; + + // Update in-memory + grab a clone for persistence outside the lock. + let updated = { + let mut g = self.inner.write(); + let m = g + .isos + .get_mut(id) + .ok_or_else(|| Error::Invalid(format!("no such iso '{id}'")))?; + m.password_hash = new_hash; + m.clone() + }; + // NFS-sourced ISOs have no on-disk meta.json — skip persistence + // for them (the password lives in memory until the manager + // re-scans the share, then it's gone). Document this in the API + // handler so the operator knows. + if matches!(updated.source, IsoSource::Local) { + self.persist_meta(&updated).await?; + } + Ok(()) + } + + /// Verify a candidate password against the stored bcrypt hash. + /// Returns: + /// - `Ok(true)` — match (or the ISO has no password set; gate is open) + /// - `Ok(false)` — mismatch + /// - `Err(_)` — id not found, or bcrypt error + pub fn verify_password(&self, id: &str, candidate: &str) -> Result { + let meta = self + .get(id) + .ok_or_else(|| Error::Invalid(format!("no such iso '{id}'")))?; + let Some(hash) = meta.password_hash else { + return Ok(true); // no password set — anyone can boot + }; + bcrypt::verify(candidate, &hash).map_err(|e| Error::Other(e.into())) + } } fn slugify(filename: &str) -> String { @@ -416,6 +497,8 @@ fn linux_cmdline(family: DistroFamily, id: &str) -> String { #[cfg(test)] mod tests { use super::*; + use crate::introspect::{DistroFamily, IntrospectionReport}; + use tempfile::tempdir; #[test] fn slugify_basic() { @@ -427,4 +510,83 @@ mod tests { // If a path sneaks in, file_stem strips the directory — OK, not a hazard. assert_eq!(slugify("/etc/passwd"), "passwd"); } + + fn fake_meta(id: &str) -> IsoMeta { + IsoMeta { + id: id.into(), + filename: format!("{id}.iso"), + size_bytes: 0, + sha256_hex: None, + uploaded_at: OffsetDateTime::now_utc(), + introspection: IntrospectionReport { + family: DistroFamily::Unknown, + volume_label: None, + kernel_path: None, + initrd_paths: vec![], + has_boot_wim: false, + }, + boot_entries: vec![], + source: IsoSource::Local, + password_hash: None, + } + } + + #[tokio::test] + async fn password_round_trip_set_verify_clear() { + let dir = tempdir().unwrap(); + let store = IsoStore::new(dir.path().to_path_buf()); + store.ensure_dirs().await.unwrap(); + store.inner.write().isos.insert("alpha".into(), fake_meta("alpha")); + + // No password set — verify_password returns Ok(true) for any input. + assert!(store.verify_password("alpha", "anything").unwrap()); + assert!(!store.get("alpha").unwrap().is_password_protected()); + + // Set a password. + store.set_password("alpha", Some("hunter2")).await.unwrap(); + let m = store.get("alpha").unwrap(); + assert!(m.is_password_protected()); + assert!(m.password_hash.unwrap().starts_with("$2")); + + // Verify correct + wrong. + assert!(store.verify_password("alpha", "hunter2").unwrap()); + assert!(!store.verify_password("alpha", "wrong").unwrap()); + assert!(!store.verify_password("alpha", "").unwrap()); + + // Clear by passing None or an empty string. + store.set_password("alpha", None).await.unwrap(); + assert!(!store.get("alpha").unwrap().is_password_protected()); + store.set_password("alpha", Some("again")).await.unwrap(); + store.set_password("alpha", Some(" ")).await.unwrap(); + assert!(!store.get("alpha").unwrap().is_password_protected()); + } + + #[tokio::test] + async fn set_password_for_unknown_id_errors() { + let dir = tempdir().unwrap(); + let store = IsoStore::new(dir.path().to_path_buf()); + store.ensure_dirs().await.unwrap(); + let r = store.set_password("does-not-exist", Some("pw")).await; + assert!(matches!(r, Err(Error::Invalid(_)))); + } + + #[tokio::test] + async fn password_persists_via_meta_json_for_local_isos() { + // Hash makes it onto disk so it survives a restart. + let dir = tempdir().unwrap(); + let store = IsoStore::new(dir.path().to_path_buf()); + store.ensure_dirs().await.unwrap(); + let meta = fake_meta("alpha"); + store.persist_meta(&meta).await.unwrap(); + store.insert(meta); + store.set_password("alpha", Some("s3cret")).await.unwrap(); + + // Re-load from disk and confirm the hash came back. + let store2 = IsoStore::new(dir.path().to_path_buf()); + store2.load_from_disk().await.unwrap(); + let reloaded = store2.get("alpha").expect("reloaded"); + assert!(reloaded.is_password_protected()); + assert!(store2.verify_password("alpha", "s3cret").unwrap()); + assert!(!store2.verify_password("alpha", "wrong").unwrap()); + } } diff --git a/crates/webui/src/app.js b/crates/webui/src/app.js index 4991c9b..bc4e2d4 100644 --- a/crates/webui/src/app.js +++ b/crates/webui/src/app.js @@ -372,12 +372,99 @@ } // ── ISO table (mixed local + NFS) ── - const rows = isos.map(i => { + // Each row gets a "Password" cell that toggles a small inline + // editor (a checkbox + a password field + Save button) inside the + // *next* row of the table. Keeps the markup flat and avoids the + // overhead of a real modal. + const rowsAndEditors = []; + isos.forEach(i => { const b = bootability(i, settings); const isNfs = i.source && i.source.kind === 'nfs'; + const protectedNow = !!i.password_hash; + + // The inline editor row is hidden by default; the Password + // button toggles its `display`. Pre-built so toggle is cheap. + const pwCheck = el('input', {type:'checkbox'}); + pwCheck.checked = protectedNow; + const pwInput = el('input', { + type: 'password', spellcheck: 'false', + autocomplete: 'new-password', autocapitalize: 'off', + placeholder: protectedNow ? '(unchanged — type to replace)' : 'choose a password', + }); + const pwInputWrap = el('label', {class:'field', style:'flex:1;margin:0'}, [ + el('span', {class:'name'}, 'Password'), + pwInput, + ]); + // Toggle the password field's visibility off when the checkbox + // is unchecked, so the operator's intent is unambiguous on Save. + const refreshFieldVisibility = () => { + pwInputWrap.style.display = pwCheck.checked ? '' : 'none'; + }; + pwCheck.onchange = refreshFieldVisibility; + const pwMsg = el('div', {class:'msg', style:'margin-top:6px'}); + const pwSave = el('button', {style:'flex:none', onclick: async () => { + let resp; + if (pwCheck.checked) { + // Empty input + previously protected = keep the old password + // (operator just toggled the box on but didn't type). We + // detect this by sending the API only when the field has + // content; otherwise no-op + show hint. + if (!pwInput.value && !protectedNow) { + pwMsg.textContent = 'Enter a password to enable.'; + pwMsg.className = 'msg err'; + return; + } + if (!pwInput.value && protectedNow) { + pwMsg.textContent = 'Password unchanged.'; + pwMsg.className = 'msg ok'; + return; + } + resp = await putJSON( + '/api/isos/' + encodeURIComponent(i.id) + '/password', + { password: pwInput.value }); + } else { + resp = await fetch( + '/api/isos/' + encodeURIComponent(i.id) + '/password', + {method: 'DELETE'}); + } + if (resp.ok || resp.status === 204) { + // Wipe the input field before re-rendering so the + // plaintext doesn't sit in DOM longer than necessary. + pwInput.value = ''; + render('storage'); + } else { + const t = await resp.text(); + pwMsg.textContent = 'Save failed: ' + t; + pwMsg.className = 'msg err'; + } + }}, 'Save password'); + + const editorCells = el('td', {colspan: '7', style:'background:var(--bg-panel-2);padding:14px 18px'}, [ + el('div', {style:'display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap'}, [ + el('label', {class:'check', style:'flex:none;margin:0'}, [ + pwCheck, + el('span', {}, 'Password protect this image'), + ]), + pwInputWrap, + pwSave, + ]), + el('div', {class:'msg', style:'margin-top:8px;font-size:11.5px'}, + 'Operators booting this ISO will be prompted on the PXE client. ' + + 'Stored bcrypt-hashed; the plaintext never leaves the request.'), + pwMsg, + ]); + const editorRow = el('tr', {style:'display:none'}, editorCells); + refreshFieldVisibility(); + const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [ el('td', {}, [ - el('div', {}, i.filename), + el('div', {style:'display:flex;align-items:center;gap:8px'}, [ + protectedNow ? el('span', { + title: 'Password protected', + style:'color:var(--accent);font-size:13px' + }, '🔒') : null, + el('span', {}, i.filename), + ]), !b.ok ? el('div', {class:'row-warn'}, '⚠ ' + b.reason) : (b.warn ? el('div', {class:'row-warn'}, '⚠ ' + b.warn) : null), ]), @@ -386,26 +473,35 @@ el('td', {}, el('span', {class:'src-badge' + (isNfs ? ' nfs' : '')}, isNfs ? ('nfs:' + i.source.mount_id) : 'local')), + el('td', {}, + protectedNow + ? el('span', {class:'tag accent'}, 'protected') + : el('span', {class:'tag', style:'opacity:.55'}, 'open')), el('td', {}, fmtAgo(i.uploaded_at)), - el('td', {style:'text-align:right'}, + el('td', {style:'text-align:right;white-space:nowrap'}, [ + el('button', {class:'ghost', style:'margin-right:6px', onclick: () => { + editorRow.style.display = (editorRow.style.display === 'none') ? '' : 'none'; + }}, protectedNow ? 'Password ✎' : 'Set password'), isNfs - ? el('span', {class:'tag', style:'opacity:.6'}, 'manage on NFS share') + ? el('span', {class:'tag', style:'opacity:.6'}, 'on NFS') : el('button', {class:'danger', onclick: async () => { if (!confirm('Remove this image?')) return; await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'}); render('storage'); - }}, 'Remove')), + }}, 'Remove'), + ]), ]); - return tr; + rowsAndEditors.push(tr, editorRow); }); const isoTable = isos.length ? el('table', {}, [ el('thead', {}, el('tr', {}, [ el('th',{},'Name'), el('th',{},'Type'), el('th',{class:'num'},'Size'), - el('th',{},'Source'), el('th',{},'Uploaded'), el('th',{},''), + el('th',{},'Source'), el('th',{},'Auth'), + el('th',{},'Uploaded'), el('th',{},''), ])), - el('tbody', {}, rows), + el('tbody', {}, rowsAndEditors), ]) : el('div', {class:'empty'}, 'No images yet. Upload an ISO or mount an NFS share.'); diff --git a/crates/webui/src/index.html b/crates/webui/src/index.html index 6565401..3cf7a3f 100644 --- a/crates/webui/src/index.html +++ b/crates/webui/src/index.html @@ -29,7 +29,7 @@
OpenPXE -
v0.3.0
+
v0.3.1