v0.3.1: per-ISO boot password gate
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<String>` 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/<entry>.ipxe` now intercepts:
- no `?token=` -> render password-prompt script
- `?token=<wrong>` -> render auth-fail script (sleeps 2s, chains
back to the entry which re-prompts)
- `?token=<correct>` -> 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 `<input type=password autocomplete=new-password>` (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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/<entry>.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<String>,
|
||||
}
|
||||
|
||||
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/<id>.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<bool> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user