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:
+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