v0.6.2: Mythos Validation — full-codebase polish, hot-path optimizations, dhcproto 0.15

Codebase-wide review pass: finish or remove every loose end, take the
safe performance wins on the serving hot paths, and refresh the
dependency tree for reliability. No behavior changes for working
clients; legacy clients get clearer protocol errors.

Finalize / cleanup:
- Remove mac_allowlist/subnet_allowlist config fields — parsed but never
  enforced since introduction; the operator wants line-of-sight serving,
  so the honest fix is deletion, not wiring.
- Remove dead ClientRegistry API (get, set_selected_target,
  always-None selected_target field, never-emitted DhcpRequest/
  HttpIsoAsset events).
- TFTP: reject WRQ with ERR_ILLEGAL_OP and non-octet modes with a clear
  error instead of silent timeouts (legacy-client friendliness); fold
  plan_window into cfg(test); drop the unused-constant keep-alive hack.
- rustfmt sweep over the six files with accumulated drift.

Hot-path optimizations (all behavior-preserving):
- Serve embedded iPXE binaries zero-copy (Cow over rodata) on both TFTP
  and HTTP — was a ~1 MiB heap copy per boot file request.
- Cache the composited PXE boot-menu background PNG keyed on the
  branding logo revision — was ~50-200 ms of image work per booting
  client; now one compose per logo change.
- Run bcrypt verify/hash on the blocking pool (boot password gate,
  login, setup, credential rotation) so CPU-heavy auth can't stall the
  workers streaming ISO ranges to imaging machines.
- iso_raw: reuse the already-cloned IsoMeta for path resolution instead
  of a second registry lock + deep clone per range request.
- DriverEscalation: amortize the TTL sweep (1-min interval + inline
  staleness check) instead of an O(map) retain per DHCP packet.
- format_mac: one allocation instead of four per datagram.
- Introspection haystack sized to min(scan cap, file size) — was
  guaranteed a 32 MiB realloc on every large-ISO probe.

Robustness:
- parse_range: malformed Range headers are now ignored per RFC 7233
  (200 + full body) instead of answered with a bogus 206.

Dependencies:
- dhcproto 0.12 -> 0.15: drops the deprecated/unmaintained
  trust-dns-proto from the tree (hickory-proto), three releases of DHCP
  option coverage. Compiles + passes the full suite unchanged.
- socket2 0.6 (dedupes tree), bcrypt 0.19, tower-http 0.6.11 (sheds
  iri-string), tokio 1.52.3 / hyper 1.10 lockfile refresh; dead nom
  workspace entry removed; requested versions synced to shipped reality.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Miles Ward
2026-06-09 16:44:55 -04:00
co-authored by Claude Opus 4.8
parent 4f193cac05
commit 5da05a519d
21 changed files with 659 additions and 430 deletions
+71 -14
View File
@@ -35,7 +35,7 @@ use openpxe_core::{
encoding::pct_encode, ext_for_mime, wol, BootEvent, ClientEvent, DeployProfile, Error,
LogoSlot, NotifyConfig, Settings, SsoConfig, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
};
use openpxe_ipxe_assets::asset_bytes;
use openpxe_ipxe_assets::asset_slice;
use openpxe_iso_store::{
render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SftpAddRequest, SmbAddRequest,
SmbState, UnattendedKind, UnattendedMeta,
@@ -406,6 +406,22 @@ fn bundled_logo_response() -> Response {
/// brand mark falls back to the *default* background for the PXE screen
/// (the WebUI still renders the SVG natively in the top-left).
async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
// The composite is a pure function of the uploaded logo, so the
// encoded PNG is cached keyed on the branding revision — an upload
// or clear bumps the rev and invalidates it. The response headers
// stay `no-cache` (clients must refetch); only the server-side
// ~50-200 ms decode/compose/encode is skipped per boot.
let rev = state.branding.logo_rev();
let cached = state
.pxe_bg_cache
.lock()
.as_ref()
.filter(|(r, _)| *r == rev)
.map(|(_, png)| png.clone());
if let Some(png) = cached {
return pxe_png_response(png);
}
// Resolve the operator's raster upload, if any and if it's a format
// iPXE/our compositor can consume. SVG (or a missing/unreadable
// file) yields `None`, which composes the default background.
@@ -450,6 +466,12 @@ async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
.into_response();
}
};
let png = bytes::Bytes::from(composed);
*state.pxe_bg_cache.lock() = Some((rev, png.clone()));
pxe_png_response(png)
}
fn pxe_png_response(png: bytes::Bytes) -> Response {
(
[
(header::CONTENT_TYPE, HeaderValue::from_static("image/png")),
@@ -460,7 +482,7 @@ async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
HeaderValue::from_static("no-cache, max-age=0"),
),
],
composed,
png,
)
.into_response()
}
@@ -638,7 +660,23 @@ async fn boot_sub(
));
}
Some(token) => {
match state.iso_store.verify_password(&iso.id, token) {
// bcrypt verify costs ~100-200 ms of pure
// CPU and this path is unauthenticated —
// run it on the blocking pool so password
// probes can't stall the workers that are
// streaming ISO bytes to imaging machines.
let store = state.iso_store.clone();
let iso_id = iso.id.clone();
let tok = token.to_string();
let verdict = match tokio::task::spawn_blocking(move || {
store.verify_password(&iso_id, &tok)
})
.await
{
Ok(v) => v,
Err(e) => Err(openpxe_core::Error::Other(e.into())),
};
match verdict {
Ok(true) => { /* fall through to render the entry */ }
Ok(false) => {
// Don't log the candidate — just the
@@ -735,9 +773,15 @@ async fn ipxe_binary(AxumPath(name): AxumPath<String>) -> Response {
if name.contains('/') || name.contains('\\') {
return (StatusCode::BAD_REQUEST, "invalid name").into_response();
}
let Some(bytes) = asset_bytes(&name) else {
let Some(data) = asset_slice(&name) else {
return (StatusCode::NOT_FOUND, "no such ipxe asset").into_response();
};
// Release builds embed the asset in rodata — serve it without the
// ~1 MiB per-request heap copy `into_owned` would cost.
let bytes = match data {
std::borrow::Cow::Borrowed(b) => bytes::Bytes::from_static(b),
std::borrow::Cow::Owned(v) => bytes::Bytes::from(v),
};
(
[
(
@@ -768,7 +812,10 @@ async fn iso_raw(
};
match &meta.source {
IsoSource::Local => {
let Some(path) = state.iso_store.iso_path_for(id) else {
// `local_path(&meta)` reuses the meta we already cloned —
// `iso_path_for(id)` would re-lock and deep-clone it again,
// hundreds of times per sanboot install.
let Some(path) = state.iso_store.local_path(&meta) else {
return (StatusCode::NOT_FOUND, "no such iso").into_response();
};
match stream_file_range(&path, headers.get(header::RANGE)).await {
@@ -1027,15 +1074,25 @@ fn parse_range(h: Option<&HeaderValue>, total: u64) -> Option<(u64, u64, bool)>
return Some((total.saturating_sub(n), total.saturating_sub(1), true));
}
}
let mut parts = spec.splitn(2, '-');
let start = parts
.next()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let end = parts
.next()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(total.saturating_sub(1));
// RFC 7233 §3.1: a Range header we can't parse is *ignored* (200 +
// full body), never coerced into a bogus 206 claiming the whole
// file. Only `first-pos[-last-pos]` with numeric positions reaches
// the partial path; `None` is reserved for syntactically valid but
// unsatisfiable ranges (→ 416).
let full = Some((0, total.saturating_sub(1), false));
let Some((start_s, end_s)) = spec.split_once('-') else {
return full;
};
let Ok(start) = start_s.trim().parse::<u64>() else {
return full;
};
let end = if end_s.trim().is_empty() {
total.saturating_sub(1)
} else if let Ok(e) = end_s.trim().parse::<u64>() {
e
} else {
return full;
};
if start >= total {
return None;
}
+33 -9
View File
@@ -154,10 +154,15 @@ pub fn session_cookie(session: &str) -> String {
fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
// `Cookie: a=b; c=d` parsing — small enough not to drag in a crate.
// Two-step strip (name, then '=') keeps this allocation-free per
// candidate and can't match a longer cookie name sharing the prefix.
let raw = headers.get(header::COOKIE)?.to_str().ok()?;
for part in raw.split(';') {
let part = part.trim();
if let Some(v) = part.strip_prefix(&format!("{SESSION_COOKIE}=")) {
if let Some(v) = part
.strip_prefix(SESSION_COOKIE)
.and_then(|rest| rest.strip_prefix('='))
{
return Some(v.to_string());
}
}
@@ -246,7 +251,14 @@ pub async fn api_setup(State(state): State<AppState>, Json(body): Json<SetupBody
)
.into_response();
}
match state.admin.bootstrap(&body.username, &body.password) {
// bcrypt hashing is ~100-200 ms of pure CPU (and `bootstrap` also
// persists to disk synchronously) — keep it off the async workers.
let admin = state.admin.clone();
let result =
tokio::task::spawn_blocking(move || admin.bootstrap(&body.username, &body.password))
.await
.unwrap_or_else(|e| Err(openpxe_core::Error::Other(e.into())));
match result {
Ok(pub_) => {
let session = state.sessions.create(&pub_.username);
login_response(StatusCode::CREATED, &pub_, &session)
@@ -271,8 +283,13 @@ pub struct LoginBody {
pub async fn api_login(State(state): State<AppState>, Json(body): Json<LoginBody>) -> Response {
// Brief, deliberately vague — "invalid credentials" rather than
// "no such user" / "wrong password". Same anti-enumeration posture
// as Sonarr/Radarr.
let pub_ = match state.admin.verify(&body.username, &body.password) {
// as Sonarr/Radarr. The bcrypt verify is ~100-200 ms of pure CPU on
// an unauthenticated endpoint, so it runs on the blocking pool.
let admin = state.admin.clone();
let verdict = tokio::task::spawn_blocking(move || admin.verify(&body.username, &body.password))
.await
.unwrap_or_else(|e| Err(openpxe_core::Error::Other(e.into())));
let pub_ = match verdict {
Ok(Some(u)) => u,
Ok(None) => {
return (
@@ -394,11 +411,18 @@ pub async fn api_update_credentials(
)
.into_response();
}
let result = state.admin.update_credentials(
&body.current_password,
body.new_username.as_deref(),
body.new_password.as_deref(),
);
// Two bcrypt operations (verify current + hash new) plus a sync disk
// persist — run the lot on the blocking pool.
let admin = state.admin.clone();
let result = tokio::task::spawn_blocking(move || {
admin.update_credentials(
&body.current_password,
body.new_username.as_deref(),
body.new_password.as_deref(),
)
})
.await
.unwrap_or_else(|e| Err(openpxe_core::Error::Other(e.into())));
match result {
Ok(pub_) => {
state.sessions.revoke_all();
+5 -1
View File
@@ -105,7 +105,11 @@ async fn send_email(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(),
.trim()
.parse()
.map_err(|e| format!("invalid To address '{}': {e}", cfg.smtp_to))?)
.subject(if subject.is_empty() { "OpenPXE" } else { subject })
.subject(if subject.is_empty() {
"OpenPXE"
} else {
subject
})
.body(body.to_string())
.map_err(|e| format!("could not build email: {e}"))?;
+10
View File
@@ -11,6 +11,10 @@ use openpxe_iso_store::{
use std::sync::Arc;
use time::OffsetDateTime;
/// Cached composited PXE boot-menu background: `(logo_rev, encoded PNG)`.
/// See `AppState::pxe_bg_cache`.
pub type PxeBgCache = Arc<parking_lot::Mutex<Option<(u64, bytes::Bytes)>>>;
#[derive(Clone)]
pub struct AppState {
pub iso_store: IsoStore,
@@ -29,6 +33,12 @@ pub struct AppState {
/// operator hasn't uploaded anything, the WebUI serves the bundled
/// rainbow-horizon mark.
pub branding: BrandingStore,
/// v0.6.2: cache of the composited PXE boot-menu background PNG,
/// keyed on the branding logo revision. Composing costs ~50-200 ms
/// of image decode/encode and **every** booting client fetches it
/// for `console --picture` — caching makes that one compose per
/// logo change instead of one per boot.
pub pxe_bg_cache: PxeBgCache,
/// Forms-auth admin record + first-run bootstrap state. When
/// `admin.is_configured() == false`, the auth middleware passes
/// every request through and `/api/me` reports `setup_required`.