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:
Miles Ward
2026-05-06 22:35:11 -04:00
parent 90a23a8c96
commit 9c6903351f
8 changed files with 608 additions and 12 deletions
+117 -1
View File
@@ -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<String>,
}
#[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<String>,
}
async fn boot_sub(
State(state): State<AppState>,
AxumPath(filename): AxumPath<String>,
Query(p): Query<BootSubParams>,
) -> Response {
// `/boot/<name>.ipxe` where `<name>` 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<String>,
}
async fn api_set_iso_password(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
Json(body): Json<SetPasswordBody>,
) -> 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<AppState>,
AxumPath(id): AxumPath<String>,
) -> 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<AppState>,
mut multipart: Multipart,
+91 -1
View File
@@ -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"));
}
}
+130
View File
@@ -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/<id>.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/<id>.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);
}