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:
+2
-1
@@ -12,7 +12,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.80"
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -53,6 +53,7 @@ uuid = { version = "1.10", features = ["v4", "serde"] }
|
||||
time = { version = "0.3", features = ["serde", "serde-human-readable", "formatting", "macros"] }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
bcrypt = "0.15"
|
||||
once_cell = "1.19"
|
||||
parking_lot = "0.12"
|
||||
rust-embed = { version = "8.5", features = ["include-exclude"] }
|
||||
|
||||
@@ -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.
|
||||
@@ -208,6 +224,51 @@ async fn boot_sub(
|
||||
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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+104
-8
@@ -372,12 +372,99 @@
|
||||
}
|
||||
|
||||
// ── ISO table (mixed local + NFS) ──
|
||||
const rows = isos.map(i => {
|
||||
// Each row gets a "Password" cell that toggles a small inline
|
||||
// editor (a checkbox + a password field + Save button) inside the
|
||||
// *next* row of the table. Keeps the markup flat and avoids the
|
||||
// overhead of a real modal.
|
||||
const rowsAndEditors = [];
|
||||
isos.forEach(i => {
|
||||
const b = bootability(i, settings);
|
||||
const isNfs = i.source && i.source.kind === 'nfs';
|
||||
const protectedNow = !!i.password_hash;
|
||||
|
||||
// The inline editor row is hidden by default; the Password
|
||||
// button toggles its `display`. Pre-built so toggle is cheap.
|
||||
const pwCheck = el('input', {type:'checkbox'});
|
||||
pwCheck.checked = protectedNow;
|
||||
const pwInput = el('input', {
|
||||
type: 'password', spellcheck: 'false',
|
||||
autocomplete: 'new-password', autocapitalize: 'off',
|
||||
placeholder: protectedNow ? '(unchanged — type to replace)' : 'choose a password',
|
||||
});
|
||||
const pwInputWrap = el('label', {class:'field', style:'flex:1;margin:0'}, [
|
||||
el('span', {class:'name'}, 'Password'),
|
||||
pwInput,
|
||||
]);
|
||||
// Toggle the password field's visibility off when the checkbox
|
||||
// is unchecked, so the operator's intent is unambiguous on Save.
|
||||
const refreshFieldVisibility = () => {
|
||||
pwInputWrap.style.display = pwCheck.checked ? '' : 'none';
|
||||
};
|
||||
pwCheck.onchange = refreshFieldVisibility;
|
||||
const pwMsg = el('div', {class:'msg', style:'margin-top:6px'});
|
||||
const pwSave = el('button', {style:'flex:none', onclick: async () => {
|
||||
let resp;
|
||||
if (pwCheck.checked) {
|
||||
// Empty input + previously protected = keep the old password
|
||||
// (operator just toggled the box on but didn't type). We
|
||||
// detect this by sending the API only when the field has
|
||||
// content; otherwise no-op + show hint.
|
||||
if (!pwInput.value && !protectedNow) {
|
||||
pwMsg.textContent = 'Enter a password to enable.';
|
||||
pwMsg.className = 'msg err';
|
||||
return;
|
||||
}
|
||||
if (!pwInput.value && protectedNow) {
|
||||
pwMsg.textContent = 'Password unchanged.';
|
||||
pwMsg.className = 'msg ok';
|
||||
return;
|
||||
}
|
||||
resp = await putJSON(
|
||||
'/api/isos/' + encodeURIComponent(i.id) + '/password',
|
||||
{ password: pwInput.value });
|
||||
} else {
|
||||
resp = await fetch(
|
||||
'/api/isos/' + encodeURIComponent(i.id) + '/password',
|
||||
{method: 'DELETE'});
|
||||
}
|
||||
if (resp.ok || resp.status === 204) {
|
||||
// Wipe the input field before re-rendering so the
|
||||
// plaintext doesn't sit in DOM longer than necessary.
|
||||
pwInput.value = '';
|
||||
render('storage');
|
||||
} else {
|
||||
const t = await resp.text();
|
||||
pwMsg.textContent = 'Save failed: ' + t;
|
||||
pwMsg.className = 'msg err';
|
||||
}
|
||||
}}, 'Save password');
|
||||
|
||||
const editorCells = el('td', {colspan: '7', style:'background:var(--bg-panel-2);padding:14px 18px'}, [
|
||||
el('div', {style:'display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap'}, [
|
||||
el('label', {class:'check', style:'flex:none;margin:0'}, [
|
||||
pwCheck,
|
||||
el('span', {}, 'Password protect this image'),
|
||||
]),
|
||||
pwInputWrap,
|
||||
pwSave,
|
||||
]),
|
||||
el('div', {class:'msg', style:'margin-top:8px;font-size:11.5px'},
|
||||
'Operators booting this ISO will be prompted on the PXE client. ' +
|
||||
'Stored bcrypt-hashed; the plaintext never leaves the request.'),
|
||||
pwMsg,
|
||||
]);
|
||||
const editorRow = el('tr', {style:'display:none'}, editorCells);
|
||||
refreshFieldVisibility();
|
||||
|
||||
const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [
|
||||
el('td', {}, [
|
||||
el('div', {}, i.filename),
|
||||
el('div', {style:'display:flex;align-items:center;gap:8px'}, [
|
||||
protectedNow ? el('span', {
|
||||
title: 'Password protected',
|
||||
style:'color:var(--accent);font-size:13px'
|
||||
}, '🔒') : null,
|
||||
el('span', {}, i.filename),
|
||||
]),
|
||||
!b.ok ? el('div', {class:'row-warn'}, '⚠ ' + b.reason)
|
||||
: (b.warn ? el('div', {class:'row-warn'}, '⚠ ' + b.warn) : null),
|
||||
]),
|
||||
@@ -386,26 +473,35 @@
|
||||
el('td', {},
|
||||
el('span', {class:'src-badge' + (isNfs ? ' nfs' : '')},
|
||||
isNfs ? ('nfs:' + i.source.mount_id) : 'local')),
|
||||
el('td', {},
|
||||
protectedNow
|
||||
? el('span', {class:'tag accent'}, 'protected')
|
||||
: el('span', {class:'tag', style:'opacity:.55'}, 'open')),
|
||||
el('td', {}, fmtAgo(i.uploaded_at)),
|
||||
el('td', {style:'text-align:right'},
|
||||
el('td', {style:'text-align:right;white-space:nowrap'}, [
|
||||
el('button', {class:'ghost', style:'margin-right:6px', onclick: () => {
|
||||
editorRow.style.display = (editorRow.style.display === 'none') ? '' : 'none';
|
||||
}}, protectedNow ? 'Password ✎' : 'Set password'),
|
||||
isNfs
|
||||
? el('span', {class:'tag', style:'opacity:.6'}, 'manage on NFS share')
|
||||
? el('span', {class:'tag', style:'opacity:.6'}, 'on NFS')
|
||||
: el('button', {class:'danger', onclick: async () => {
|
||||
if (!confirm('Remove this image?')) return;
|
||||
await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'});
|
||||
render('storage');
|
||||
}}, 'Remove')),
|
||||
}}, 'Remove'),
|
||||
]),
|
||||
]);
|
||||
return tr;
|
||||
rowsAndEditors.push(tr, editorRow);
|
||||
});
|
||||
const isoTable = isos.length
|
||||
? el('table', {}, [
|
||||
el('thead', {}, el('tr', {}, [
|
||||
el('th',{},'Name'), el('th',{},'Type'),
|
||||
el('th',{class:'num'},'Size'),
|
||||
el('th',{},'Source'), el('th',{},'Uploaded'), el('th',{},''),
|
||||
el('th',{},'Source'), el('th',{},'Auth'),
|
||||
el('th',{},'Uploaded'), el('th',{},''),
|
||||
])),
|
||||
el('tbody', {}, rows),
|
||||
el('tbody', {}, rowsAndEditors),
|
||||
])
|
||||
: el('div', {class:'empty'}, 'No images yet. Upload an ISO or mount an NFS share.');
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<img src="/assets/logo.svg" alt="" />
|
||||
<div>
|
||||
<strong>OpenPXE</strong>
|
||||
<div class="sub">v<span data-bind="version">0.3.0</span></div>
|
||||
<div class="sub">v<span data-bind="version">0.3.1</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<nav>
|
||||
|
||||
Reference in New Issue
Block a user