//! Axum router, handlers, and API endpoints. //! //! Route groups: //! //! | Group | Purpose | //! |------------------|-------------------------------------------------------| //! | `/` | Web UI (served from `openpxe-webui`) | //! | `/boot.ipxe` | Top-level iPXE menu | //! | `/boot/_*.ipxe` | Submenu scripts (hierarchy: linux, windows, tools, …) | //! | `/boot/.ipxe`| Per-entry boot script | //! | `/ipxe/` | Bundled iPXE binaries + wimboot + memtest | //! | `/iso/.iso` | Raw ISO with Range support | //! | `/iso//*` | Files inside the ISO (for wimboot & kernel/initrd) | //! | `/api/*` | JSON/HTML API for the web UI | use crate::auth as auth_api; 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, }; use crate::iso_fs; use crate::log_stream; use crate::state::AppState; use crate::terminal; use axum::{ body::{Body, Bytes}, extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path as AxumPath, Query, State}, http::{header, HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Response}, routing::{delete, get, post, put}, Json, Router, }; use openpxe_core::{ ext_for_mime, BootEvent, ClientEvent, Error, Settings, SsoConfig, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES, }; use openpxe_ipxe_assets::asset_bytes; use openpxe_iso_store::{IsoCategory, IsoMeta, NfsAddRequest}; use serde::Deserialize; use serde_json::json; use std::net::SocketAddr; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncSeekExt}; use tower_http::trace::TraceLayer; pub fn build_router(state: AppState) -> Router { Router::new() // Web UI (fully offline — no CDN, no external fonts/images). .route("/", get(index)) .route("/assets/app.js", get(ui_js)) .route("/assets/app.css", get(ui_css)) .route("/assets/logo.svg", get(ui_logo)) .route("/assets/loader.svg", get(ui_loader)) // iPXE script endpoints. .route("/boot.ipxe", get(boot_top_menu)) .route("/boot/:filename", get(boot_sub)) // Bundled binaries and raw ISO access. .route("/ipxe/:name", get(ipxe_binary)) .route("/iso/:filename", get(iso_raw)) .route("/iso/:id/*path", get(iso_file)) // Container health/readiness probes. `/healthz` is always 200 OK // while the HTTP task is alive. `/readyz` additionally requires at // least one bundled iPXE binary (without one, no client can PXE). .route("/healthz", get(healthz)) .route("/readyz", get(readyz)) // JSON API. .route("/api/isos", get(api_list_isos).post(api_upload_iso)) .route("/api/isos/:id", delete(api_delete_iso)) .route("/api/uploads", post(api_upload_begin)) .route( "/api/uploads/:upload_id", put(api_upload_chunk).delete(api_upload_abort), ) // Per-ISO password prompt. 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), ) // v0.4.4: per-ISO menu category (Os / Tools). Drives whether the // image appears under Linux/Windows Installers (default) or in // the Tools submenu next to memtest / shell / NIC info. .route( "/api/isos/:id/category", axum::routing::put(api_set_iso_category), ) // v0.4.4: filesystem free-space telemetry for the ISO directory's // volume — surfaced as a small card on the Storage tab so the // operator knows when they're about to run out of room. .route("/api/storage/disk", get(api_storage_disk)) // v0.4.4: operator-controlled WebUI branding overrides // (custom logo). Multipart upload to POST; DELETE clears. .route( "/api/branding/logo", post(api_branding_upload).delete(api_branding_clear), ) // v0.4.4: self-rendered API reference, served as JSON so the UI // can format it consistently with the rest of the chrome. Lives // under the Settings tab — operators chasing an integration get // it in-product instead of having to fetch the OpenAPI YAML. .route("/api/docs", get(api_docs)) // v0.4.5: Sonarr/Radarr-style admin Forms auth. First-run // /setup creates the single admin account; /login validates; // /logout revokes the session; /me powers the front-end's // "should I show the setup page, the login page, or the // dashboard?" decision. /me/credentials rotates the admin's // username/password. .route("/api/setup", post(auth_api::api_setup)) .route("/api/login", post(auth_api::api_login)) .route("/api/logout", post(auth_api::api_logout)) .route("/api/me", get(auth_api::api_me)) .route( "/api/me/credentials", put(auth_api::api_update_credentials), ) // v0.4.5: SAML SSO configuration (FleetDM-shaped, storage-only). // The actual sign-in flow lands in a later release; this just // gives operators a place to paste their IdP metadata today. .route("/api/sso", get(api_sso_get).put(api_sso_put)) .route("/api/clients", get(api_list_clients)) .route("/api/status", get(api_status)) .route("/api/settings", get(api_get_settings).put(api_put_settings)) .route("/api/queue", get(api_list_queue)) .route("/api/queue/join", get(api_queue_join)) .route("/api/queue/poll/:entry_id", get(api_queue_poll)) .route("/api/queue/assign", post(api_queue_assign)) .route("/api/queue/:entry_id", delete(api_queue_release)) // Phase 4: NFS share manager. .route("/api/nfs", get(api_nfs_list).post(api_nfs_add)) .route("/api/nfs/:id", delete(api_nfs_remove)) .route("/api/nfs/:id/scan", post(api_nfs_scan)) // Phase 4: Network info (read-only) + DNS edit. .route("/api/network", get(api_network).put(api_network_put)) // Phase 4: live-log stream + recent buffer for the Terminal tab. .route("/api/log/stream", get(log_stream::stream)) .route("/api/log/recent", get(log_stream::recent)) .route("/api/log/clear", post(log_stream::clear)) // Phase 4: operator terminal commands (whitelisted). .route("/api/terminal", post(terminal::run_command)) // Phase 5: per-MAC host bindings. Operator // pins a MAC to a boot entry; /boot.ipxe?mac=... chains directly. .route("/api/hosts", get(api_hosts_list).post(api_hosts_upsert)) .route("/api/hosts/:mac", delete(api_hosts_remove)) // Rolling "host log" of boot events: what image actually // started installing on what MAC/IP, and when. Persisted to disk. .route("/api/boot-log", get(api_boot_log)) // Phase 5: Prometheus scrape endpoint. Plain text exposition // format. No auth — the metrics surface is intentionally // boring (counts, no payloads). .route("/metrics", get(api_metrics)) // v0.4.5: Forms-auth middleware. Layered *after* `.route(...)` // calls so it applies uniformly; passes everything through when // no admin is configured (tests + fresh installs ride this path). // The allowlist inside `auth_api::require_auth` keeps PXE-essential // endpoints reachable for iPXE clients that can't authenticate. .layer(axum::middleware::from_fn_with_state( state.clone(), auth_api::require_auth, )) .layer(TraceLayer::new_for_http()) // 16 GiB upload cap — ISOs are big; chunks stream so this isn't memory use. .layer(DefaultBodyLimit::max(16 * 1024 * 1024 * 1024)) .with_state(state) } // ─── SSO config endpoints ───────────────────────────────────────────────── async fn api_sso_get(State(state): State) -> Json { // We deliberately do not redact the metadata — the operator who's // signed in needs to be able to round-trip it. /api/sso requires // the auth middleware anyway, so unauthenticated callers can't see // it once admin is configured. Json(state.sso.snapshot()) } async fn api_sso_put(State(state): State, Json(body): Json) -> 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(), } } // ─── UI ──────────────────────────────────────────────────────────────────── async fn index(State(state): State) -> Response { let html = openpxe_webui::index_html(&state.public_base_url); ( [( header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"), )], html, ) .into_response() } async fn ui_js() -> Response { ( [( header::CONTENT_TYPE, HeaderValue::from_static("application/javascript"), )], openpxe_webui::app_js(), ) .into_response() } async fn ui_css() -> Response { ( [(header::CONTENT_TYPE, HeaderValue::from_static("text/css"))], openpxe_webui::app_css(), ) .into_response() } async fn ui_logo(State(state): State) -> Response { // Custom override first; fall back to the bundled rainbow-horizon // SVG. We resolve the override on each request rather than caching // because operators may upload/clear from the Settings tab while the // server is live, and we want them to see their change immediately // without bouncing the binary. if let Some(path) = state.branding.logo_path() { let mime = state .branding .logo_mime() .unwrap_or_else(|| "image/svg+xml".to_string()); match tokio::fs::read(&path).await { Ok(bytes) => { let ct = match HeaderValue::from_str(&mime) { Ok(v) => v, Err(_) => HeaderValue::from_static("application/octet-stream"), }; return ( [ (header::CONTENT_TYPE, ct), ( header::CACHE_CONTROL, HeaderValue::from_static("no-cache, max-age=0"), ), ], bytes, ) .into_response(); } Err(e) => { tracing::warn!( target: "openpxe::http::branding", error = %e, "failed to read custom logo; falling back to bundled" ); } } } ( [( header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml"), )], openpxe_webui::logo_svg(), ) .into_response() } async fn ui_loader() -> Response { ( [( header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml"), )], openpxe_webui::loader_svg(), ) .into_response() } // ─── iPXE scripts ────────────────────────────────────────────────────────── fn text_plain(body: String) -> Response { ( [( header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8"), )], body, ) .into_response() } /// Top-level boot script. Honors per-MAC host bindings: if the /// requesting client carries a `?mac=...` query param (iPXE's `${mac}` /// substitution) and that MAC has a binding, we short-circuit straight /// to the bound target instead of rendering the menu. async fn boot_top_menu( State(state): State, peer: Option>, Query(p): Query, ) -> Response { // `ConnectInfo` is only populated when axum was started with // `into_make_service_with_connect_info` (production path). Tests // call the router via `oneshot`, which skips that wiring — we // tolerate it by treating the peer as unknown rather than 500ing. let peer_ip = peer.map(|c| c.0.ip()); state .metrics .record_http(openpxe_core::HttpRoute::BootScript); let isos = state.iso_store.list(); let settings = state.settings.snapshot(); let base = &state.public_base_url; // Per-MAC override: if the client identified itself and we have a // binding, chain directly. The chain target falls back to the menu // on failure so a stale / misconfigured binding can't lock a client // out — it just shows the menu. if let Some(mac) = p.mac.as_deref() { if let Some(binding) = state.hosts.lookup(mac) { tracing::info!( target: "openpxe::http", mac = %binding.mac, target = %binding.target, "host binding applied" ); // Pre-record the host-binding event. Reserved menu shortcuts // (`_local`, `_queue`, …) are operator-driven non-imaging // targets — recording them would clutter the Host log with // routine console activity, so we skip those and only record // for real boot-entry ids. if !binding.target.starts_with('_') { let title = lookup_entry_title(&isos, &binding.target); state.boot_log.record(&BootEvent { timestamp: time::OffsetDateTime::now_utc(), mac: Some(binding.mac.clone()), ip: peer_ip, target_id: binding.target.clone(), target_title: title, }); } let target = binding.target; let bound_mac = binding.mac; // Reserved menu shortcuts are emitted as `_xxx`; per-entry // boot scripts are at `/boot/.ipxe`. Both share the same // `/boot/` route, so the URL is identical. We forward // `?mac=` so the per-entry handler can record the boot into // the Host log without depending on iPXE substitution at // this stage. return text_plain(format!( "#!ipxe\n\ echo OpenPXE: per-MAC binding -> {target}\n\ chain {base}/boot/{target}.ipxe?mac={bound_mac} || chain {base}/boot.ipxe\n" )); } } text_plain(render_menu(&isos, &settings, base)) } /// Best-effort human title for a boot entry id — falls back to the id /// itself if the ISO has been deleted between record-time and now. fn lookup_entry_title(isos: &[openpxe_iso_store::IsoMeta], target_id: &str) -> String { for iso in isos { for e in &iso.boot_entries { if e.id == target_id { // ISO filename plus the entry title gives the operator // both "which image" and "which variant" (e.g. wimboot // vs sanboot) at a glance. return format!("{} — {}", iso.filename, e.title); } } } target_id.to_string() } #[derive(Debug, Deserialize)] struct BootMenuParams { /// Client MAC, supplied by iPXE via `${mac}` variable in /// `chain ${prefix}/boot.ipxe?mac=${mac}`. Optional — if absent we /// fall back to the menu unconditionally. mac: Option, } #[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, /// Client MAC, supplied by iPXE via `${mac}` in the chain URLs we /// render. Optional — older bookmarks may omit it; the boot log /// just records `None` in that case rather than refusing to boot. mac: Option, } async fn boot_sub( State(state): State, peer: Option>, AxumPath(filename): AxumPath, Query(p): Query, ) -> Response { let peer_ip = peer.map(|c| c.0.ip()); // `/boot/.ipxe` where `` is either one of our reserved // submenu names (prefixed `_`) or a boot entry id. let name = filename.strip_suffix(".ipxe").unwrap_or(&filename); let isos = state.iso_store.list(); let settings = state.settings.snapshot(); let base = &state.public_base_url; let script = match name { "_local" => render_local_hdd(base), "_linux_menu" => render_family_menu(&isos, base, false), "_windows_menu" => render_family_menu(&isos, base, true), "_tools_menu" => render_tools_menu(&isos, base), "_util" => render_util(base), "_shell" => render_shell(base), "_nic" => render_nic_info(base), "_queue" => render_queue_entry(base), other => { for iso in &isos { for entry in &iso.boot_entries { if entry.id == other { // Password prompt. 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(); } } } } } // Record the boot event. This is the canonical // moment: password gate (if any) passed, and the // script is about to be served — i.e. the client // is genuinely about to start imaging. let mac_normalized = p .mac .as_deref() .map(openpxe_core::normalize_mac) .filter(|m| !m.is_empty()); state.boot_log.record(&BootEvent { timestamp: time::OffsetDateTime::now_utc(), mac: mac_normalized, ip: peer_ip, target_id: entry.id.clone(), target_title: format!("{} — {}", iso.filename, entry.title), }); return text_plain(render_entry(entry, &settings, base)); } } } return (StatusCode::NOT_FOUND, "no such boot entry").into_response(); } }; text_plain(script) } // ─── bundled iPXE binaries (memtest lives here too) ─────────────────────── async fn ipxe_binary(AxumPath(name): AxumPath) -> Response { if name.contains('/') || name.contains('\\') { return (StatusCode::BAD_REQUEST, "invalid name").into_response(); } let Some(bytes) = asset_bytes(&name) else { return (StatusCode::NOT_FOUND, "no such ipxe asset").into_response(); }; ( [ ( header::CONTENT_TYPE, HeaderValue::from_static("application/octet-stream"), ), (header::CONTENT_LENGTH, HeaderValue::from(bytes.len())), ], bytes, ) .into_response() } // ─── ISO streaming (raw + in-ISO) ───────────────────────────────────────── async fn iso_raw( State(state): State, AxumPath(filename): AxumPath, headers: HeaderMap, ) -> Response { let id = filename.strip_suffix(".iso").unwrap_or(&filename); let Some(path) = state.iso_store.iso_path_for(id) else { return (StatusCode::NOT_FOUND, "no such iso").into_response(); }; match stream_file_range(&path, headers.get(header::RANGE)).await { Ok(r) => r, Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), } } async fn iso_file( State(state): State, AxumPath((id, path)): AxumPath<(String, String)>, ) -> Response { let Some(iso_path) = state.iso_store.iso_path_for(&id) else { return (StatusCode::NOT_FOUND, "no such iso").into_response(); }; let p = iso_path.clone(); let in_path = format!("/{path}"); let loc = tokio::task::spawn_blocking(move || iso_fs::lookup(&p, &in_path)) .await .ok() .flatten(); let Some(loc) = loc else { return (StatusCode::NOT_FOUND, "not found inside iso").into_response(); }; match stream_byte_range(&iso_path, loc.offset, loc.length).await { Ok(r) => r, Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), } } async fn stream_file_range( path: &std::path::Path, range: Option<&HeaderValue>, ) -> anyhow::Result { let meta = tokio::fs::metadata(path).await?; let total = meta.len(); if total == 0 { return Ok(Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "application/octet-stream") .header(header::ACCEPT_RANGES, "bytes") .header(header::CONTENT_LENGTH, 0) .body(Body::empty()) .unwrap()); } let Some((start, end, partial)) = parse_range(range, total) else { return Ok(Response::builder() .status(StatusCode::RANGE_NOT_SATISFIABLE) .header(header::CONTENT_RANGE, format!("bytes */{total}")) .body(Body::empty()) .unwrap()); }; let len = end - start + 1; let mut file = tokio::fs::File::open(path).await?; file.seek(std::io::SeekFrom::Start(start)).await?; let reader = file.take(len); let stream = tokio_util::io::ReaderStream::new(reader); let body = Body::from_stream(stream); let status = if partial { StatusCode::PARTIAL_CONTENT } else { StatusCode::OK }; let mut builder = Response::builder() .status(status) .header(header::CONTENT_TYPE, "application/octet-stream") .header(header::ACCEPT_RANGES, "bytes") .header(header::CONTENT_LENGTH, len); if partial { builder = builder.header( header::CONTENT_RANGE, format!("bytes {start}-{end}/{total}"), ); } Ok(builder.body(body).unwrap()) } async fn stream_byte_range( path: &std::path::Path, offset: u64, length: u64, ) -> anyhow::Result { let mut file = tokio::fs::File::open(path).await?; file.seek(std::io::SeekFrom::Start(offset)).await?; let reader = file.take(length); let stream = tokio_util::io::ReaderStream::new(reader); let body = Body::from_stream(stream); Ok(Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "application/octet-stream") .header(header::CONTENT_LENGTH, length) .body(body) .unwrap()) } fn parse_range(h: Option<&HeaderValue>, total: u64) -> Option<(u64, u64, bool)> { let Some(h) = h else { return Some((0, total.saturating_sub(1), false)); }; let Ok(s) = h.to_str() else { return Some((0, total.saturating_sub(1), false)); }; let Some(spec) = s.strip_prefix("bytes=") else { return Some((0, total.saturating_sub(1), false)); }; let spec = spec.split(',').next().unwrap_or("").trim(); if let Some(suffix) = spec.strip_prefix('-') { if let Ok(n) = suffix.parse::() { let n = n.min(total); 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::().ok()) .unwrap_or(0); let end = parts .next() .and_then(|s| s.parse::().ok()) .unwrap_or(total.saturating_sub(1)); if start >= total { return None; } let end = end.min(total.saturating_sub(1)); if start > end { return None; } Some((start, end, true)) } // ─── ISO upload / list / delete ─────────────────────────────────────────── async fn api_list_isos(State(state): State) -> Json> { Json(state.iso_store.list()) } async fn api_delete_iso( State(state): State, AxumPath(id): AxumPath, ) -> StatusCode { match state.iso_store.delete(&id).await { Ok(()) => StatusCode::NO_CONTENT, Err(_) => StatusCode::INTERNAL_SERVER_ERROR, } } #[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, } async fn api_set_iso_password( State(state): State, AxumPath(id): AxumPath, Json(body): Json, ) -> 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(openpxe_error_invalid) if matches!(openpxe_error_invalid, openpxe_core::Error::Invalid(_)) => { (StatusCode::NOT_FOUND, format!("{openpxe_error_invalid}")).into_response() } Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), } } async fn api_clear_iso_password( State(state): State, AxumPath(id): AxumPath, ) -> 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(), } } // ─── ISO category (OS / Tools) ──────────────────────────────────────────── #[derive(Debug, Deserialize)] struct SetCategoryBody { /// `"os"` or `"tools"` — matches `IsoCategory`'s snake_case serde /// repr. Anything else returns 400 with the allowed set spelled out. category: String, } async fn api_set_iso_category( State(state): State, AxumPath(id): AxumPath, Json(body): Json, ) -> Response { let cat = match body.category.as_str() { "os" => IsoCategory::Os, "tools" => IsoCategory::Tools, other => { return ( StatusCode::BAD_REQUEST, format!("unknown category '{other}'; expected one of: os, tools"), ) .into_response(); } }; match state.iso_store.set_category(&id, cat).await { Ok(meta) => { tracing::info!( target: "openpxe::http::iso", iso = %id, category = ?cat, "iso category updated" ); (StatusCode::OK, Json(meta)).into_response() } Err(Error::Invalid(msg)) => (StatusCode::NOT_FOUND, msg).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), } } // ─── Disk space (Storage tab) ───────────────────────────────────────────── async fn api_storage_disk(State(state): State) -> Json { // statvfs on the directory that holds the ISO store. We deliberately // don't walk the directory ourselves — the kernel already tracks // free/total at the volume level and that's the only number the // operator actually cares about for "do I have room for one more // 5 GB ISO?". `statvfs` itself lives in iso-store to keep the // http-api crate free of `unsafe`. let dir = state.iso_store.iso_dir(); let (total, available) = state.iso_store.disk_usage().unwrap_or((0, 0)); let used = total.saturating_sub(available); Json(json!({ "path": dir.to_string_lossy(), "total_bytes": total, "available_bytes": available, "used_bytes": used, })) } // ─── Branding (custom logo) ─────────────────────────────────────────────── async fn api_branding_upload( State(state): State, mut multipart: Multipart, ) -> Response { while let Ok(Some(field)) = multipart.next_field().await { let name = field.name().unwrap_or("").to_string(); if name != "file" && name != "logo" { continue; } let mime = field.content_type().unwrap_or("").to_string(); if !ALLOWED_LOGO_MIMES.iter().any(|m| *m == mime) { return ( StatusCode::BAD_REQUEST, format!( "unsupported MIME '{mime}'. Allowed: {}", ALLOWED_LOGO_MIMES.join(", ") ), ) .into_response(); } // Pre-read into memory so we can enforce the size cap before // hitting disk. Logos are tiny by definition. let bytes = match field.bytes().await { Ok(b) => b, Err(e) => { return (StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response() } }; if bytes.len() > MAX_LOGO_BYTES { return ( StatusCode::PAYLOAD_TOO_LARGE, format!( "logo too large ({} bytes, max {})", bytes.len(), MAX_LOGO_BYTES ), ) .into_response(); } let Some(ext) = ext_for_mime(&mime) else { return (StatusCode::BAD_REQUEST, "unsupported MIME").into_response(); }; match state.branding.set_logo(&mime, ext, &bytes) { Ok(filename) => { return ( StatusCode::OK, Json(json!({ "filename": filename, "mime": mime, "size_bytes": bytes.len(), })), ) .into_response() } Err(e) => { return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response() } } } (StatusCode::BAD_REQUEST, "no 'file' part").into_response() } async fn api_branding_clear(State(state): State) -> Response { match state.branding.clear_logo() { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), } } // ─── API reference (Settings → bottom) ──────────────────────────────────── async fn api_docs() -> Json { // Hand-curated rather than introspected from axum because: // 1. axum's runtime route table doesn't carry parameter docs; // 2. the WebUI surfaces this as a readable list, not as an OpenAPI // spec — readers are operators chasing an integration, not // machines. // Keep this in lockstep with `build_router` when adding endpoints. Json(json!({ "version": env!("CARGO_PKG_VERSION"), "groups": [ { "name": "Status & health", "endpoints": [ {"method": "GET", "path": "/healthz", "summary": "Liveness — always 200 OK while the HTTP task is alive."}, {"method": "GET", "path": "/readyz", "summary": "Readiness — 200 only when iPXE binaries are bundled and the ISO directory is readable."}, {"method": "GET", "path": "/api/status", "summary": "Dashboard JSON — versions, counts, settings snapshot, uptime."}, {"method": "GET", "path": "/metrics", "summary": "Prometheus text exposition (counters + gauges)."}, ], }, { "name": "ISO images", "endpoints": [ {"method": "GET", "path": "/api/isos", "summary": "List ISOs (local + NFS) with size, family, boot entries, category."}, {"method": "POST", "path": "/api/isos", "summary": "Legacy single-shot multipart upload. Prefer /api/uploads for big files."}, {"method": "DELETE", "path": "/api/isos/:id", "summary": "Delete a local ISO and its sidecar metadata."}, {"method": "PUT", "path": "/api/isos/:id/password", "summary": "Set or update an ISO's boot password (bcrypt-hashed; plaintext never stored)."}, {"method": "DELETE", "path": "/api/isos/:id/password", "summary": "Clear an ISO's boot password."}, {"method": "PUT", "path": "/api/isos/:id/category", "summary": "Set the menu category. Body: { \"category\": \"os\" | \"tools\" }."}, ], }, { "name": "Chunked uploads", "endpoints": [ {"method": "POST", "path": "/api/uploads", "summary": "Begin a chunked upload session. Body: { \"filename\", \"size_bytes\" }."}, {"method": "PUT", "path": "/api/uploads/:upload_id", "summary": "Append a chunk. Headers: x-openpxe-upload-offset, x-openpxe-upload-complete."}, {"method": "DELETE", "path": "/api/uploads/:upload_id", "summary": "Abort a chunked upload session and remove the .partial file."}, ], }, { "name": "NFS shares", "endpoints": [ {"method": "GET", "path": "/api/nfs", "summary": "List configured NFS shares with mount state and iso counts."}, {"method": "POST", "path": "/api/nfs", "summary": "Mount an NFS share. Body: { server, export, version, read_only }."}, {"method": "DELETE", "path": "/api/nfs/:id", "summary": "Unmount a share and drop its entries from the ISO store."}, {"method": "POST", "path": "/api/nfs/:id/scan", "summary": "Re-walk a mounted share for ISOs."}, ], }, { "name": "Network", "endpoints": [ {"method": "GET", "path": "/api/network", "summary": "Detected NIC, server IP, subnet, gateway, advertised base URL."}, {"method": "PUT", "path": "/api/network", "summary": "Update the informational DNS server hint (does not run DNS)."}, ], }, { "name": "Settings", "endpoints": [ {"method": "GET", "path": "/api/settings", "summary": "Current runtime settings (Windows toggle, timeout, dns hint, …)."}, {"method": "PUT", "path": "/api/settings", "summary": "Replace runtime settings. Guards against enabling Windows when wimboot isn't bundled."}, {"method": "POST", "path": "/api/branding/logo", "summary": "Upload a custom WebUI logo (multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB)."}, {"method": "DELETE", "path": "/api/branding/logo", "summary": "Remove the custom logo and revert to the bundled mark."}, {"method": "GET", "path": "/api/sso", "summary": "Current SAML SSO configuration."}, {"method": "PUT", "path": "/api/sso", "summary": "Replace SAML SSO configuration. Body: { enabled, idp_name, metadata, metadata_url }."}, {"method": "GET", "path": "/api/docs", "summary": "This API reference."}, ], }, { "name": "Auth (Forms)", "endpoints": [ {"method": "POST", "path": "/api/setup", "summary": "First-run admin bootstrap. Body: { username, password }. Refuses after the admin exists."}, {"method": "POST", "path": "/api/login", "summary": "Sign in. Body: { username, password }. Sets the openpxe_session cookie."}, {"method": "POST", "path": "/api/logout", "summary": "Revoke the current session and clear the cookie."}, {"method": "GET", "path": "/api/me", "summary": "Auth status — { setup_required, authenticated, user }. Always 200."}, {"method": "PUT", "path": "/api/me/credentials", "summary": "Rotate the admin's credentials. Body: { current_password, new_username?, new_password? }. Revokes all other sessions on success."}, ], }, { "name": "Storage telemetry", "endpoints": [ {"method": "GET", "path": "/api/storage/disk", "summary": "Free / used / total bytes for the volume hosting the ISO directory."}, ], }, { "name": "Queued Deployment", "endpoints": [ {"method": "GET", "path": "/api/queue", "summary": "List queue entries (waiting + assigned)."}, {"method": "POST", "path": "/api/queue/assign", "summary": "Assign a target image to queued clients. Body: { target, entry_ids }."}, {"method": "DELETE", "path": "/api/queue/:entry_id", "summary": "Release a queue entry without assigning."}, ], }, { "name": "Hosts & boot log", "endpoints": [ {"method": "GET", "path": "/api/hosts", "summary": "List per-MAC boot bindings."}, {"method": "POST", "path": "/api/hosts", "summary": "Pin a MAC to a boot target. Body: { mac, target, label }."}, {"method": "DELETE", "path": "/api/hosts/:mac", "summary": "Remove a binding."}, {"method": "GET", "path": "/api/boot-log", "summary": "Ring of recent boot events (timestamp, mac, ip, target)."}, ], }, { "name": "Operator console", "endpoints": [ {"method": "GET", "path": "/api/clients", "summary": "Live PXE-client registry — MAC, last IP, arch, events."}, {"method": "GET", "path": "/api/log/recent", "summary": "Ring of recent server log lines for the Terminal tab."}, {"method": "GET", "path": "/api/log/stream", "summary": "Server-Sent Events stream of log lines."}, {"method": "POST", "path": "/api/terminal", "summary": "Run a whitelisted operator command. Body: { command }."}, ], }, ], })) } async fn api_upload_iso(State(state): State, mut multipart: Multipart) -> Response { // Walk multipart parts until we find the file. Each branch logs so an // operator chasing a "stuck" upload in the Terminal tab can see // exactly which stage failed (no field, wrong field name, parser // error, mid-stream drop, sha mismatch on finish, etc.). loop { let field_res = multipart.next_field().await; match field_res { Ok(Some(mut field)) => { if field.name() != Some("file") { tracing::debug!( target: "openpxe::http::upload", field = field.name().unwrap_or("?"), "skipping non-file multipart part" ); continue; } let filename = field.file_name().unwrap_or("uploaded.iso").to_string(); if !filename.to_ascii_lowercase().ends_with(".iso") { tracing::warn!( target: "openpxe::http::upload", filename = %filename, "rejecting non-.iso upload" ); return (StatusCode::BAD_REQUEST, "only .iso uploads accepted").into_response(); } tracing::info!( target: "openpxe::http::upload", filename = %filename, "upload started" ); let mut handle = match state.iso_store.begin_upload(&filename).await { Ok(h) => h, Err(e) => { tracing::warn!( target: "openpxe::http::upload", filename = %filename, error = %e, "begin_upload rejected (likely duplicate name)" ); return (StatusCode::CONFLICT, format!("{e}")).into_response(); } }; // Streamed reader loop. We use an explicit `match` instead // of `while let Ok(Some(_))` so a mid-stream `Err(_)` (a // truncated body from a reverse proxy 524 / network drop) // is treated as a failure rather than silently completing // with a partial file. let mut bytes: u64 = 0; let mut next_log_at: u64 = 64 * 1024 * 1024; loop { match field.chunk().await { Ok(Some(chunk)) => { if let Err(e) = handle.write_chunk(&chunk).await { tracing::error!( target: "openpxe::http::upload", filename = %filename, bytes, error = %e, "write_chunk failed; aborting" ); let _ = handle.abort().await; return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")) .into_response(); } bytes += chunk.len() as u64; if bytes >= next_log_at { tracing::info!( target: "openpxe::http::upload", filename = %filename, received_bytes = bytes, "upload streaming" ); // Backoff log cadence: 64 MB, 128, 256, … next_log_at = next_log_at.saturating_mul(2); } } Ok(None) => break, Err(e) => { tracing::error!( target: "openpxe::http::upload", filename = %filename, received_bytes = bytes, error = %e, "multipart stream ended with error (likely client \ disconnect or reverse-proxy buffer cap); aborting" ); let _ = handle.abort().await; return ( StatusCode::BAD_REQUEST, format!( "upload truncated after {bytes} bytes: {e}. \ If you went through a reverse proxy, try the \ LAN IP directly — large body buffering caps \ (Cloudflare free tier is 100 MB) commonly \ cause this." ), ) .into_response(); } } } tracing::info!( target: "openpxe::http::upload", filename = %filename, received_bytes = bytes, "upload body complete; introspecting" ); let meta = match handle.finish(&state.iso_store).await { Ok(m) => m, Err(e) => { tracing::error!( target: "openpxe::http::upload", filename = %filename, error = %e, "finish failed (rename/introspect)" ); return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(); } }; tracing::info!( target: "openpxe::http::upload", iso = %meta.id, size = meta.size_bytes, family = ?meta.introspection.family, entries = meta.boot_entries.len(), "upload finished" ); return (StatusCode::CREATED, Json(meta)).into_response(); } Ok(None) => { tracing::warn!(target: "openpxe::http::upload", "upload had no 'file' part"); return (StatusCode::BAD_REQUEST, "no 'file' part").into_response(); } Err(e) => { tracing::error!( target: "openpxe::http::upload", error = %e, "multipart parser error before reading any field" ); return ( StatusCode::BAD_REQUEST, format!("multipart parse error: {e}"), ) .into_response(); } } } } #[derive(Debug, Deserialize)] struct UploadBeginBody { filename: String, #[serde(default)] size_bytes: Option, } async fn api_upload_begin( State(state): State, Json(body): Json, ) -> Response { match state .uploads .begin(&state.iso_store, &body.filename, body.size_bytes) .await { Ok(started) => { tracing::info!( target: "openpxe::http::upload", upload_id = %started.upload_id, iso = %started.iso_id, filename = %started.filename, expected_size = ?body.size_bytes, "chunked upload started" ); (StatusCode::CREATED, Json(started)).into_response() } Err(Error::Invalid(e)) if e.contains("already exists") => { (StatusCode::CONFLICT, e).into_response() } Err(Error::Invalid(e)) => (StatusCode::BAD_REQUEST, e).into_response(), Err(e) => { tracing::error!(target: "openpxe::http::upload", error = %e, "chunked upload begin failed"); (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response() } } } async fn api_upload_chunk( State(state): State, AxumPath(upload_id): AxumPath, headers: HeaderMap, chunk: Bytes, ) -> Response { let Some(offset) = parse_u64_header(&headers, "x-openpxe-upload-offset") else { return ( StatusCode::BAD_REQUEST, "missing or invalid x-openpxe-upload-offset", ) .into_response(); }; let complete = bool_header(&headers, "x-openpxe-upload-complete"); match state .uploads .append(&state.iso_store, &upload_id, offset, chunk, complete) .await { Ok(crate::uploads::UploadAppend::Progress { offset }) => ( StatusCode::ACCEPTED, Json(json!({ "ok": true, "upload_id": upload_id, "offset": offset, "complete": false, })), ) .into_response(), Ok(crate::uploads::UploadAppend::Complete { offset, iso }) => { tracing::info!( target: "openpxe::http::upload", upload_id = %upload_id, iso = %iso.id, size = iso.size_bytes, family = ?iso.introspection.family, entries = iso.boot_entries.len(), "chunked upload finished" ); ( StatusCode::CREATED, Json(json!({ "ok": true, "upload_id": upload_id, "offset": offset, "complete": true, "iso": iso, })), ) .into_response() } Err(Error::Invalid(e)) if e.starts_with("expected offset") => { (StatusCode::CONFLICT, e).into_response() } Err(Error::Invalid(e)) if e.starts_with("no such upload") => { (StatusCode::NOT_FOUND, e).into_response() } Err(Error::Invalid(e)) => (StatusCode::BAD_REQUEST, e).into_response(), Err(e) => { tracing::error!( target: "openpxe::http::upload", upload_id = %upload_id, error = %e, "chunked upload failed" ); (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response() } } } async fn api_upload_abort( State(state): State, AxumPath(upload_id): AxumPath, ) -> Response { match state.uploads.abort(&upload_id).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(Error::Invalid(e)) if e.starts_with("no such upload") => { (StatusCode::NOT_FOUND, e).into_response() } Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), } } fn parse_u64_header(headers: &HeaderMap, name: &'static str) -> Option { headers.get(name)?.to_str().ok()?.trim().parse::().ok() } fn bool_header(headers: &HeaderMap, name: &'static str) -> bool { headers .get(name) .and_then(|v| v.to_str().ok()) .map(str::trim) .is_some_and(|v| matches!(v, "1" | "true" | "TRUE" | "yes" | "YES")) } // ─── health / readiness ─────────────────────────────────────────────────── async fn healthz() -> Response { // Simple liveness — HTTP task is responsive. Does not touch storage or // other subsystems so we never fail for downstream reasons. ( [(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))], "ok\n", ) .into_response() } async fn readyz(State(state): State) -> Response { // Readiness — can we actually serve clients? // 1. At least one iPXE binary must be bundled (without one, TFTP 404s // and no client boots). // 2. The ISO directory must exist and be readable. let assets = openpxe_ipxe_assets::list_assets(); let mut problems: Vec<&str> = Vec::new(); if assets.is_empty() { problems.push("no iPXE binaries bundled (run scripts/fetch-ipxe.sh before building)"); } let iso_dir_ok = state.iso_store.list_ok(); if !iso_dir_ok { problems.push("iso directory not readable"); } if problems.is_empty() { ( [(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))], "ready\n", ) .into_response() } else { let body = format!("not ready:\n- {}\n", problems.join("\n- ")); (StatusCode::SERVICE_UNAVAILABLE, body).into_response() } } // ─── clients + status + settings ────────────────────────────────────────── async fn api_list_clients(State(state): State) -> Json { Json(json!({ "clients": state.clients.list() })) } async fn api_status(State(state): State) -> Json { let smb = state.smb.as_ref().map(|s| s.snapshot()); let nfs = state.nfs.list(); let nfs_active = nfs.iter().filter(|m| m.mounted).count(); let isos = state.iso_store.list(); let clients = state.clients.list(); let queue_entries = state.queue.list(); // Phase 4: dashboard tracks "imaging" as queue entries with an assignment // already issued — they're the ones actively chaining a boot script. let imaging = queue_entries .iter() .filter(|entry| entry.assigned_target.is_some()) .count(); let waiting = queue_entries.len() - imaging; // Side-effect: push gauge values out to the Prometheus surface. // Doing it here (in the most-frequently-polled endpoint) keeps the // gauges fresh without a dedicated scrape-time hook. state.metrics.set_iso_count(isos.len() as u64); state.metrics.set_client_count(clients.len() as u64); state .metrics .set_queue_counts(queue_entries.len() as u64, imaging as u64); state.metrics.set_nfs_active(nfs_active as u64); 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, "nfs_count": nfs.len(), "nfs_active": nfs_active, "host_bindings": state.hosts.len(), "custom_logo": state.branding.has_logo(), "uptime_secs": uptime_secs, "started_at": state.started_at, "nic_name": state.nic_name, "subnet_mask": state.subnet_mask, "gateway": state.gateway, })) } async fn api_get_settings(State(state): State) -> Json { Json(state.settings.snapshot()) } async fn api_put_settings( State(state): State, Json(mut new): Json, ) -> Response { // Guardrail: Windows boot requires the wimboot shim to be bundled. // Without it, clients chain a non-existent /ipxe/wimboot and stall. if new.windows_enabled { let assets = openpxe_ipxe_assets::list_assets(); if !assets.iter().any(|n| n == "wimboot") { return ( StatusCode::BAD_REQUEST, "cannot enable Windows: 'wimboot' binary is not bundled. \ Place a signed wimboot build at assets/ipxe/wimboot and rebuild \ the container. See docs/architecture.md for details.", ) .into_response(); } } new.smb_host_override = new.smb_host_override.trim().to_string(); // Detect whether this PUT changes the Windows toggle, so we only // restart smbd when it actually flipped. let was_enabled = state.settings.snapshot().windows_enabled; let want_enabled = new.windows_enabled; state.settings.replace(new); if let Some(smb) = &state.smb { match (was_enabled, want_enabled) { (false, true) => { let _ = smb.start(); } (true, false) => { smb.stop(); } (true, true) => { let _ = smb.reconcile(); } (false, false) => {} } } StatusCode::NO_CONTENT.into_response() } // ─── Queued Deployment API ───────────────────────────────────────────────── async fn api_list_queue(State(state): State) -> Json { Json(json!({ "entries": state.queue.list(), "count": state.queue.list().len(), })) } #[derive(Debug, Deserialize)] struct QueueJoinParams { /// Client MAC from iPXE's `${mac}` variable. iPXE substitutes before /// the HTTP request so we receive a plain colon-separated MAC. mac: Option, } /// Called by iPXE via `chain --replace`. We respond with a tiny iPXE /// script that hard-loops on `/api/queue/poll/`. iPXE keeps fetching /// until poll returns an actual boot script. async fn api_queue_join( State(state): State, Query(p): Query, headers: HeaderMap, ) -> Response { let mac = p.mac.unwrap_or_else(|| "unknown".to_string()); let ip = headers .get("x-forwarded-for") .and_then(|h| h.to_str().ok()) .and_then(|s| s.split(',').next()) .and_then(|s| s.trim().parse().ok()); let queue_entry = state.queue.join(&mac, ip, None); state.clients.record( &mac, ip, None, ClientEvent::HttpScriptFetch { target: "queue-join".into(), }, ); let base = &state.public_base_url; // ASCII only - some iPXE builds on firmware consoles mangle non-ASCII. let script = format!( "#!ipxe\n\ echo\n\ echo ==========================================\n\ echo Queued Deployment - Queue Position {}\n\ echo Waiting for operator to assign an image\n\ echo (Ctrl-B returns to the iPXE shell)\n\ echo ==========================================\n\ chain {base}/api/queue/poll/{}\n", queue_entry.position, queue_entry.id ); text_plain(script) } /// Long-poll endpoint. Waits up to 25s for an assignment; if none, returns /// a script that loops back to itself. 25s keeps us well inside typical /// HTTP idle timeouts for iPXE and intermediaries. async fn api_queue_poll( State(state): State, AxumPath(entry_id): AxumPath, ) -> Response { let Some(notify) = state.queue.notifier(&entry_id) else { // Queue entry was released; send client back to the main menu. let base = &state.public_base_url; return text_plain(format!("#!ipxe\nchain {base}/boot.ipxe\n")); }; // Wait for an assignment or timeout. let _ = tokio::time::timeout(Duration::from_secs(25), notify.notified()).await; let snap = state.queue.touch(&entry_id); let base = &state.public_base_url; match snap { // Bind `target` directly so we can't observe an Option::None between // the guard and the unwrap (the old code had a race with concurrent // `release`). We also do NOT release the queue entry here — the web UI // operator releases it explicitly, which keeps a record of "this // machine was assigned image X" visible until the client is known // to have started. Clients that retry on transient network errors // still get a valid boot script instead of falling back to the // menu. Some(g) if g.assigned_target.is_some() => { let target = g.assigned_target.clone().unwrap_or_default(); tracing::info!( target: "openpxe::queue", entry_id=%entry_id, mac=%g.mac, target=%target, "queue assignment delivered" ); text_plain(format!( "#!ipxe\n\ echo Queue assignment received: {target}\n\ chain {base}/boot/{target}.ipxe || chain {base}/api/queue/poll/{entry_id}\n" )) } Some(g) => { // No assignment yet - loop and re-poll. Repaint position so the // UI count stays accurate if other queue entries were released meanwhile. text_plain(format!( "#!ipxe\n\ echo Queue Position {} - still waiting\n\ chain {base}/api/queue/poll/{entry_id}\n", g.position )) } None => text_plain(format!("#!ipxe\nchain {base}/boot.ipxe\n")), } } #[derive(Debug, Deserialize)] struct QueueAssignBody { /// Boot entry id (from `BootEntry::id`). Same one used in /// `/boot/.ipxe`. target: String, /// Queue entry ids to assign. Empty = assign to all currently queued clients. entry_ids: Vec, } async fn api_queue_assign( State(state): State, Json(body): Json, ) -> Json { let ids = if body.entry_ids.is_empty() { state .queue .list() .into_iter() .map(|g| g.id) .collect::>() } else { body.entry_ids }; // Guard: target must exist as a BootEntry id. let found = state .iso_store .list() .into_iter() .any(|i| i.boot_entries.iter().any(|e| e.id == body.target)); if !found { return Json(json!({ "ok": false, "error": "unknown target" })); } let n = state.queue.assign(&ids, &body.target); Json(json!({ "ok": true, "assigned": n, "target": body.target })) } async fn api_queue_release( State(state): State, AxumPath(entry_id): AxumPath, ) -> StatusCode { match state.queue.release(&entry_id) { Some(_) => StatusCode::NO_CONTENT, None => StatusCode::NOT_FOUND, } } // ─── NFS share API ───────────────────────────────────────────────────────── async fn api_nfs_list(State(state): State) -> Json { Json(json!({ "mounts": state.nfs.list() })) } async fn api_nfs_add(State(state): State, Json(req): Json) -> Response { match state.nfs.add(req).await { Ok(m) => (StatusCode::CREATED, Json(m)).into_response(), // Anything from the manager surfaces as a user-fixable validation // error — bad host, kernel without NFS support, missing // `mount.nfs`, dead server. We pass the message through verbatim // so the UI can show it to the operator. Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(), } } async fn api_nfs_remove(State(state): State, AxumPath(id): AxumPath) -> Response { match state.nfs.remove(&id).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), } } async fn api_nfs_scan(State(state): State, AxumPath(id): AxumPath) -> Response { match state.nfs.rescan(&id).await { Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(), Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(), } } // ─── Network info API ────────────────────────────────────────────────────── async fn api_network(State(state): State) -> Json { Json(json!({ "server_ip": state.public_base_url .strip_prefix("http://") .unwrap_or(&state.public_base_url), "nic_name": state.nic_name, "subnet_mask": state.subnet_mask, "gateway": state.gateway, "dns_server": state.settings.snapshot().dns_server, "public_base_url": state.public_base_url, })) } #[derive(Debug, Deserialize)] struct NetworkPut { /// Operators can set or clear an informational DNS hint. Server IP / /// NIC / mask / gateway are auto-detected and not editable from the /// UI — changing them in the wrong direction would silently break /// PXE for every client. dns_server: String, } async fn api_network_put( State(state): State, Json(body): Json, ) -> StatusCode { let mut s = state.settings.snapshot(); s.dns_server = body.dns_server.trim().to_string(); state.settings.replace(s); StatusCode::NO_CONTENT } // ─── Per-MAC host bindings ──────────────────────────────────────────────── async fn api_hosts_list(State(state): State) -> Json { Json(json!({ "hosts": state.hosts.list() })) } #[derive(Debug, Deserialize)] struct HostsUpsertBody { mac: String, target: String, #[serde(default)] label: String, } async fn api_hosts_upsert( State(state): State, Json(body): Json, ) -> Response { let mac = body.mac.trim(); if mac.is_empty() { return (StatusCode::BAD_REQUEST, "mac is required").into_response(); } // Sanity-check the target if the operator supplied a real boot // entry id (anything starting with `_` is a reserved menu shortcut // and exists by definition). let target = body.target.trim(); if !target.starts_with('_') && !state .iso_store .list() .into_iter() .any(|i| i.boot_entries.iter().any(|e| e.id == target)) { return ( StatusCode::BAD_REQUEST, format!("unknown boot entry: {target}"), ) .into_response(); } let binding = state.hosts.upsert(mac, target, body.label.trim()); (StatusCode::CREATED, Json(binding)).into_response() } async fn api_hosts_remove( State(state): State, AxumPath(mac): AxumPath, ) -> StatusCode { if state.hosts.remove(&mac) { StatusCode::NO_CONTENT } else { StatusCode::NOT_FOUND } } // ─── Boot event log ─────────────────────────────────────────────────────── async fn api_boot_log(State(state): State) -> Json { Json(json!({ "events": state.boot_log.list() })) } // ─── Prometheus metrics ─────────────────────────────────────────────────── async fn api_metrics(State(state): State) -> Response { // Refresh gauges from live state before rendering — keeps the // scrape "honest" without making /api/status the only path that // updates them. state .metrics .set_iso_count(state.iso_store.list().len() as u64); state .metrics .set_client_count(state.clients.list().len() as u64); let queue_entries = state.queue.list(); let imaging = queue_entries .iter() .filter(|entry| entry.assigned_target.is_some()) .count(); state .metrics .set_queue_counts(queue_entries.len() as u64, imaging as u64); state .metrics .set_nfs_active(state.nfs.list().iter().filter(|m| m.mounted).count() as u64); let now = time::OffsetDateTime::now_utc(); let uptime = (now - state.started_at).whole_seconds().max(0) as u64; let body = state.metrics.render(env!("CARGO_PKG_VERSION"), uptime); ( [( header::CONTENT_TYPE, HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"), )], body, ) .into_response() } #[cfg(test)] mod tests { use super::*; #[test] fn range_full() { let (s, e, p) = parse_range(None, 1000).unwrap(); assert_eq!((s, e, p), (0, 999, false)); } #[test] fn range_open_ended() { let h = HeaderValue::from_static("bytes=500-"); let (s, e, p) = parse_range(Some(&h), 1000).unwrap(); assert_eq!((s, e, p), (500, 999, true)); } #[test] fn range_suffix() { let h = HeaderValue::from_static("bytes=-100"); let (s, e, p) = parse_range(Some(&h), 1000).unwrap(); assert_eq!((s, e, p), (900, 999, true)); } #[test] fn range_explicit() { let h = HeaderValue::from_static("bytes=10-99"); let (s, e, p) = parse_range(Some(&h), 1000).unwrap(); assert_eq!((s, e, p), (10, 99, true)); } #[test] fn range_rejects_out_of_bounds_start() { let h = HeaderValue::from_static("bytes=1000-"); let got = parse_range(Some(&h), 1000); assert_eq!(got, None); } #[test] fn range_rejects_start_after_end() { let h = HeaderValue::from_static("bytes=99-10"); let got = parse_range(Some(&h), 1000); assert_eq!(got, None); } }