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:
+117
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user