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:
@@ -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/<id>.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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user