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