v0.5.4: code-cleanup pass (AppError, figment config, encoding dedup, typed status, deps)

Final cleanup before hardware testing. No behaviour changes; 248 tests green,
clippy clean.

#1  AppError newtype (http-api/src/error.rs) with one IntoResponse mapping
    (NotFound→404, Invalid→400, _→500) + From<core::Error>/From<io::Error>.
    Converted the clearly-safe handlers (sso_put, unattended_upload,
    branding_clear) to `?`; intentionally left handlers with bespoke
    status semantics (Invalid→404 on category, 409 on duplicate share /
    open upload) explicit so no asserted status changes.
#2  figment-based Config::load (defaults → TOML → env). Keeps the historical
    flat OPENPXE_* names (Unraid/entrypoint compatible) AND adds the nested
    OPENPXE_SECTION__FIELD form; now covers every field (apply_env had
    silently skipped unattended_dir + bind addrs). 6 Jail tests prove
    backward-compat. Removed the hand-rolled apply_env.
#3  thiserror 1→2; dropped unused mime/mime_guess/once_cell deps.
#4  Re-evaluated: Duration::from_hours/from_mins are stable on the pinned
    1.95 toolchain and clippy prefers them — kept the readable form
    (the "unstable" premise didn't hold; MSRV is intentionally 1.95).
#5  insta snapshot of the rendered iPXE menu (version-filtered) + wiremock
    coverage of the SAML metadata-URL fetch (200 + non-2xx).
#6  api_status → typed StatusResponse struct (was a 25-key json! blob) with
    a full_flow guard test asserting every UI key + the started_at string
    shape. Deferred the /api/docs typed conversion (lowest value, highest
    churn, zero functional benefit).
#7  pct_encode/xml_escape de-duplicated into openpxe_core::encoding (were
    copied across app.rs + the SAML modules). No new crates.
#8  UploadSessions registry → parking_lot::RwLock (sync, never held across
    .await); per-session lock stays tokio::Mutex.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Miles Ward
2026-06-03 03:33:05 -04:00
co-authored by Claude Opus 4.8
parent 7358013093
commit 674a69f93b
18 changed files with 807 additions and 199 deletions
+102 -76
View File
@@ -14,6 +14,7 @@
//! | `/api/*` | JSON/HTML API for the web UI |
use crate::auth as auth_api;
use crate::error::AppError;
use crate::ipxe_script::{
render_entry, render_family_menu, render_local_hdd, render_menu, render_nic_info,
render_queue_entry, render_shell, render_tools_menu, render_util,
@@ -31,15 +32,15 @@ use axum::{
Json, Router,
};
use openpxe_core::{
ext_for_mime, wol, BootEvent, ClientEvent, DeployProfile, Error, LogoSlot, NotifyConfig,
Settings, SsoConfig, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
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_iso_store::{
render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest, UnattendedKind,
UnattendedMeta,
render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest, SmbState,
UnattendedKind, UnattendedMeta,
};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::net::SocketAddr;
use std::time::Duration;
@@ -241,12 +242,12 @@ async fn api_sso_get(State(state): State<AppState>) -> Json<SsoConfig> {
Json(state.sso.snapshot())
}
async fn api_sso_put(State(state): State<AppState>, Json(body): Json<SsoConfig>) -> Response {
match state.sso.replace(body) {
Ok(cfg) => (StatusCode::OK, Json(cfg)).into_response(),
Err(Error::Invalid(msg)) => (StatusCode::BAD_REQUEST, msg).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
async fn api_sso_put(
State(state): State<AppState>,
Json(body): Json<SsoConfig>,
) -> Result<Json<SsoConfig>, AppError> {
// v0.5.4: `?` + AppError centralizes Invalid→400 / _→500.
Ok(Json(state.sso.replace(body)?))
}
// ─── UI ────────────────────────────────────────────────────────────────────
@@ -1198,14 +1199,12 @@ async fn api_branding_upload(
async fn api_branding_clear(
State(state): State<AppState>,
AxumPath(slot): AxumPath<String>,
) -> Response {
) -> Result<Response, AppError> {
let Some(slot) = LogoSlot::parse(&slot) else {
return (StatusCode::BAD_REQUEST, "unknown logo slot").into_response();
return Ok((StatusCode::BAD_REQUEST, "unknown logo slot").into_response());
};
match state.branding.clear_logo(slot) {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
state.branding.clear_logo(slot)?;
Ok(StatusCode::NO_CONTENT.into_response())
}
// ─── Unattended answer files (v0.5.2) ──────────────────────────────────────
@@ -1221,7 +1220,9 @@ async fn api_unattended_list(State(state): State<AppState>) -> Json<serde_json::
async fn api_unattended_upload(
State(state): State<AppState>,
mut multipart: Multipart,
) -> Response {
) -> Result<Response, AppError> {
// v0.5.4: the answer-file add() maps Invalid→400 / _→500 via `?`+AppError.
// The multipart-shape 400s (missing field/filename) stay explicit.
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
if name != "file" && name != "unattended" {
@@ -1229,19 +1230,18 @@ async fn api_unattended_upload(
}
let filename = field.file_name().map(str::to_string).unwrap_or_default();
if filename.trim().is_empty() {
return (StatusCode::BAD_REQUEST, "missing filename on upload").into_response();
return Ok((StatusCode::BAD_REQUEST, "missing filename on upload").into_response());
}
let bytes = match field.bytes().await {
Ok(b) => b,
Err(e) => return (StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response(),
};
return match state.unattended.add(&filename, &bytes).await {
Ok(meta) => (StatusCode::CREATED, Json(meta)).into_response(),
Err(Error::Invalid(msg)) => (StatusCode::BAD_REQUEST, msg).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
Err(e) => {
return Ok((StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response())
}
};
let meta = state.unattended.add(&filename, &bytes).await?;
return Ok((StatusCode::CREATED, Json(meta)).into_response());
}
(StatusCode::BAD_REQUEST, "no 'file' part").into_response()
Ok((StatusCode::BAD_REQUEST, "no 'file' part").into_response())
}
async fn api_unattended_delete(
@@ -1379,23 +1379,7 @@ fn build_query(pairs: &[(&str, Option<&str>)]) -> String {
out
}
/// Minimal RFC 3986 percent-encoding for query values (unreserved set
/// passes through; everything else becomes `%XX`).
fn pct_encode(s: &str) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
let _ = write!(out, "%{b:02X}");
}
}
}
out
}
// `pct_encode` lives in `openpxe_core::encoding` (v0.5.4) — imported above.
/// Encode `(hostname, ip, mac)` into a single base64url path segment for
/// the cloud-init seed directory. Empty values become empty fields.
@@ -1948,7 +1932,49 @@ async fn api_list_clients(State(state): State<AppState>) -> Json<serde_json::Val
Json(json!({ "clients": state.clients.list() }))
}
async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
/// Per-theme branding presence, nested under [`StatusResponse::branding`].
#[derive(Serialize)]
struct BrandingStatus {
light: bool,
dark: bool,
client: bool,
rev: u64,
}
/// Dashboard status payload. v0.5.4: this replaced a 25-key hand-built
/// `json!` blob — the typed struct makes the contract with the WebUI
/// compile-checked. Field names ARE the JSON keys; do not rename without
/// updating `crates/webui/src/app.js` (a `full_flow` test guards the set).
/// `settings` / `smb` / `started_at` embed their own `Serialize` impls so
/// the wire shape is byte-identical to the previous `json!` output.
#[derive(Serialize)]
struct StatusResponse {
version: &'static str,
public_base_url: String,
iso_count: usize,
client_count: usize,
queue_count: usize,
imaging_count: usize,
waiting_count: usize,
ipxe_assets: Vec<String>,
settings: Settings,
smb: Option<SmbState>,
smb_share_count: usize,
smb_share_reachable: usize,
nfs_share_count: usize,
nfs_share_reachable: usize,
host_bindings: usize,
custom_logo: bool,
branding: BrandingStatus,
unattended_count: usize,
uptime_secs: i64,
started_at: time::OffsetDateTime,
nic_name: String,
subnet_mask: String,
gateway: String,
}
async fn api_status(State(state): State<AppState>) -> Json<StatusResponse> {
let smb = state.smb.as_ref().map(|s| s.snapshot());
// v0.4.65+v0.4.67: external storage shares — SMB (userspace
// smbclient) and NFS (in-process nfs3_client). Dashboard tile
@@ -1981,39 +2007,39 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
state.metrics.record_http(openpxe_core::HttpRoute::Api);
let now = time::OffsetDateTime::now_utc();
let uptime_secs = (now - state.started_at).whole_seconds().max(0);
Json(json!({
"version": env!("CARGO_PKG_VERSION"),
"public_base_url": state.public_base_url,
"iso_count": isos.len(),
"client_count": clients.len(),
"queue_count": queue_entries.len(),
"imaging_count": imaging,
"waiting_count": waiting,
"ipxe_assets": openpxe_ipxe_assets::list_assets(),
"settings": state.settings.snapshot(),
"smb": smb,
"smb_share_count": smb_shares.len(),
"smb_share_reachable": smb_reachable,
// v0.4.67: NFSv3 share counts. The dashboard tile sums these
// with the SMB counts above ("N shares reachable") so the
// top-line metric works regardless of protocol mix.
"nfs_share_count": nfs_shares.len(),
"nfs_share_reachable": nfs_reachable,
"host_bindings": state.hosts.len(),
"custom_logo": state.branding.has_any_web_logo(),
"branding": {
"light": state.branding.has_logo(LogoSlot::Light),
"dark": state.branding.has_logo(LogoSlot::Dark),
"client": state.branding.has_logo(LogoSlot::Client),
"rev": state.branding.logo_rev(),
Json(StatusResponse {
version: env!("CARGO_PKG_VERSION"),
public_base_url: state.public_base_url.clone(),
iso_count: isos.len(),
client_count: clients.len(),
queue_count: queue_entries.len(),
imaging_count: imaging,
waiting_count: waiting,
ipxe_assets: openpxe_ipxe_assets::list_assets(),
settings: state.settings.snapshot(),
smb,
smb_share_count: smb_shares.len(),
smb_share_reachable: smb_reachable,
// v0.4.67: NFSv3 share counts. The dashboard tile sums these with
// the SMB counts above ("N shares reachable") so the top-line
// metric works regardless of protocol mix.
nfs_share_count: nfs_shares.len(),
nfs_share_reachable: nfs_reachable,
host_bindings: state.hosts.len(),
custom_logo: state.branding.has_any_web_logo(),
branding: BrandingStatus {
light: state.branding.has_logo(LogoSlot::Light),
dark: state.branding.has_logo(LogoSlot::Dark),
client: state.branding.has_logo(LogoSlot::Client),
rev: state.branding.logo_rev(),
},
"unattended_count": state.unattended.len(),
"uptime_secs": uptime_secs,
"started_at": state.started_at,
"nic_name": state.nic_name,
"subnet_mask": state.subnet_mask,
"gateway": state.gateway,
}))
unattended_count: state.unattended.len(),
uptime_secs,
started_at: state.started_at,
nic_name: state.nic_name.clone(),
subnet_mask: state.subnet_mask.clone(),
gateway: state.gateway.clone(),
})
}
async fn api_get_settings(State(state): State<AppState>) -> Json<Settings> {