//! 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::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, }; 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::{ 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_slice; use openpxe_iso_store::{ render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SftpAddRequest, SmbAddRequest, SmbState, UnattendedKind, UnattendedMeta, }; use serde::{Deserialize, Serialize}; 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)) // v0.5.2: theme-aware brand mark. `?theme=light|dark` selects the // operator's per-theme logo slot (falling back across themes, then // to the bundled mark). The WebUI swaps `?theme=` on theme toggle. .route("/assets/logo.svg", get(ui_logo)) // v0.5.2: favicon is pinned to the *bundled* OpenPXE mark for // continuity — it never follows the operator's custom branding, so // the browser-tab icon stays recognisably "OpenPXE". .route("/assets/favicon.svg", get(ui_favicon)) .route("/assets/loader.svg", get(ui_loader)) // v0.4.6: PXE menu logo — the raster form of the operator's // uploaded mark, served so iPXE's `console --picture` can // overlay it on the boot menu. SVG uploads 404 here (iPXE // can't rasterize SVG); we deliberately don't bundle a // pre-rendered PNG fallback because iPXE's ASCII wordmark // banner already provides the always-visible branding. .route("/branding/pxe-logo", get(ui_pxe_logo)) // iPXE script endpoints. .route("/boot.ipxe", get(boot_top_menu)) .route("/boot/{filename}", get(boot_sub)) // v0.5.2: unattended answer-file *serving* — public (like /iso), // because the booting installer fetches these with no session. // `/unattended/:id` serves a Kickstart/Preseed with `{{HOSTNAME}}` // / `{{IP}}` / `{{MAC}}` substituted from the query string. The // 3-segment form is the cloud-init NoCloud seed dir for Ubuntu // autoinstall (`…//user-data` + `/meta-data`), where `` // base64url-encodes the per-host hostname/ip/mac. Management // (upload/list/delete) lives under the gated `/api/unattended`. .route("/unattended/{id}", get(serve_unattended)) .route("/unattended/{id}/{ctx}/{sub}", get(serve_unattended_seed)) // 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). v0.5.2: split into three slots — `light` / `dark` / // `client`. Multipart upload to POST; DELETE clears one slot. .route( "/api/branding/logo/{slot}", post(api_branding_upload).delete(api_branding_clear), ) // v0.5.2: unattended-install answer-file management (gated). // Multipart upload, list, delete. Serving is the public // `/unattended/*` routes above. .route( "/api/unattended", get(api_unattended_list).post(api_unattended_upload), ) .route("/api/unattended/{id}", delete(api_unattended_delete)) // 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)) // SAML SSO configuration (FleetDM-shaped). Gated behind auth — the // operator pastes their IdP metadata, Entity ID, and toggles here. .route("/api/sso", get(api_sso_get).put(api_sso_put)) // v0.5.1: SAML SP login flow (pre-auth — see the require_auth // allowlist). /login redirects to the IdP, /acs consumes the signed // response + mints a session, /metadata serves our SP descriptor. .route("/api/sso/login", get(crate::saml_routes::sso_login)) .route("/api/sso/acs", post(crate::saml_routes::sso_acs)) .route("/api/sso/metadata", get(crate::saml_routes::sso_metadata)) .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)) // v0.5.2: per-device deployment profile (auto hostname / IP / // unattended file) set from the queue "Profile" button. .route("/api/queue/{entry_id}/profile", put(api_queue_set_profile)) .route("/api/queue/{entry_id}", delete(api_queue_release)) // v0.4.65: SMB share manager (userspace via smbclient). The // kernel-mount NFS routes that v0.4.64 shipped are gone — they // didn't work on hosts whose kernel lacked the nfs client // modules (Unraid), and no container-side configuration could // load a host kernel module. `smbclient` speaks SMB over a // plain TCP socket in userspace, works in every container. .route( "/api/smb-shares", get(api_smb_shares_list).post(api_smb_shares_add), ) .route("/api/smb-shares/{id}", delete(api_smb_shares_remove)) .route("/api/smb-shares/{id}/scan", post(api_smb_shares_scan)) // v0.4.67: NFSv3 share manager (pure-Rust in-process client). // Ships alongside SMB. Routes are parallel so the UI can // reuse the same form/error/hint rendering for both. .route( "/api/nfs-shares", get(api_nfs_shares_list).post(api_nfs_shares_add), ) .route("/api/nfs-shares/{id}", delete(api_nfs_shares_remove)) .route("/api/nfs-shares/{id}/scan", post(api_nfs_shares_scan)) // v0.5.5: SFTP-over-SSH share manager (pure-Rust russh client). // Parallel to SMB/NFS so the UI reuses the same form/error/hint // rendering. Like NFS, SFTP-sourced ISOs support Range requests. .route( "/api/sftp-shares", get(api_sftp_shares_list).post(api_sftp_shares_add), ) .route("/api/sftp-shares/{id}", delete(api_sftp_shares_remove)) .route("/api/sftp-shares/{id}/scan", post(api_sftp_shares_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)) // v0.5.0: Wake-on-LAN a bound host. Sends a magic packet to the // limited broadcast + the server's own subnet broadcast. .route("/api/hosts/{mac}/wol", post(api_hosts_wol)) // 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)) // v0.5.0: notification config (Advanced tab) + a "send test" // probe. GET redacts the SMTP password. .route("/api/notify", get(api_notify_get).put(api_notify_put)) .route("/api/notify/test", post(api_notify_test)) // v0.5.0: About-tab update check — queries the Gitea releases // API and compares against the running version. .route("/api/updates/check", get(api_updates_check)) // 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, ) -> Result, AppError> { // v0.5.4: `?` + AppError centralizes Invalid→400 / _→500. Ok(Json(state.sso.replace(body)?)) } // ─── UI ──────────────────────────────────────────────────────────────────── async fn index(State(state): State) -> Response { // The asset version pin in index.html (`?v=…`) is what makes // browsers re-fetch JS/CSS after an upgrade. We use the OpenPXE // binary version — every release ships a new value, every release // forces a fresh URL on each asset. // logo_rev cache-busts the brand mark / favicon independently of // the release version, so an operator who swaps the custom logo // sees it update on the next reload without waiting for an upgrade. let html = openpxe_webui::index_html( &state.public_base_url, env!("CARGO_PKG_VERSION"), state.branding.logo_rev(), state.branding.has_any_web_logo(), ); ( [ ( header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"), ), // index.html itself must never be cached — that's how the // browser learns about a new `?v=…` value for the assets. ( header::CACHE_CONTROL, HeaderValue::from_static("no-cache, must-revalidate"), ), ], html, ) .into_response() } /// Cache-Control header value used for the bundled JS/CSS/SVG assets. /// We pin a 1-day TTL so a long-lived deployment doesn't re-fetch the /// same bytes on every page-load, but require revalidation — combined /// with the `?v=` query string in index.html, the practical /// upper bound on caching across an upgrade is "until the operator /// reloads". const ASSET_CACHE_CONTROL: HeaderValue = HeaderValue::from_static("no-cache, must-revalidate"); async fn ui_js() -> Response { ( [ ( header::CONTENT_TYPE, HeaderValue::from_static("application/javascript"), ), (header::CACHE_CONTROL, ASSET_CACHE_CONTROL), ], openpxe_webui::app_js(), ) .into_response() } async fn ui_css() -> Response { ( [ (header::CONTENT_TYPE, HeaderValue::from_static("text/css")), (header::CACHE_CONTROL, ASSET_CACHE_CONTROL), ], openpxe_webui::app_css(), ) .into_response() } #[derive(Debug, Deserialize)] struct LogoQuery { /// `light` or `dark` — which theme variant the page is currently /// showing. Anything else (or absent) resolves to the dark slot, /// which matches the default theme. #[serde(default)] theme: Option, } async fn ui_logo(State(state): State, Query(q): Query) -> 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. The theme query selects the per-theme // slot, with cross-theme + bundled fallback handled in BrandingStore. let theme_is_light = q.theme.as_deref() == Some("light"); if let Some((path, mime)) = state.branding.web_logo(theme_is_light) { match tokio::fs::read(&path).await { Ok(bytes) => { let ct = HeaderValue::from_str(&mime) .unwrap_or_else(|_| 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" ); } } } bundled_logo_response() } /// Favicon — always the bundled OpenPXE mark, decoupled from operator /// branding (v0.5.2) so the browser-tab icon stays "OpenPXE" for /// continuity regardless of any uploaded light/dark logo. async fn ui_favicon() -> Response { bundled_logo_response() } fn bundled_logo_response() -> Response { ( [ ( header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml"), ), (header::CACHE_CONTROL, ASSET_CACHE_CONTROL), ], openpxe_webui::logo_svg(), ) .into_response() } /// PXE boot-menu background, composed for the iPXE `console --picture` /// call. Returns a full-screen 1024×768 PNG: a dark field with the /// operator's uploaded logo across the top, or — when no logo is set — /// a default OpenPXE mark on the same dark field. Either way the /// endpoint *always* returns a valid PNG so the menu's `console /// --picture` paints a real background instead of falling through to /// bare text (v0.4.69 — replaces the old ASCII wordmark). /// /// Any raster the operator uploads (PNG / JPEG / WebP / GIF) is decoded /// and transcoded to PNG here, since iPXE only consumes PNG. SVG /// uploads can't be rasterized without hauling in `resvg`, so an SVG /// 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) -> 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. let raster: Option> = match state.branding.client_logo() { Some((path, mime)) if mime != "image/svg+xml" => tokio::fs::read(&path).await.ok(), _ => None, }; let composed = match tokio::task::spawn_blocking(move || { openpxe_iso_store::pxe_logo::compose_pxe_background(raster.as_deref()) }) .await { Ok(Ok(png)) => png, Ok(Err(e)) => { // A decode failure on the operator's upload shouldn't blank // the boot screen — fall back to the default background. tracing::warn!( target: "openpxe::http::branding", error = %e, "PXE background compose failed on upload; using default" ); match tokio::task::spawn_blocking(|| { openpxe_iso_store::pxe_logo::compose_pxe_background(None) }) .await { Ok(Ok(png)) => png, _ => { return ( StatusCode::INTERNAL_SERVER_ERROR, "failed to compose PXE background", ) .into_response(); } } } Err(e) => { return ( StatusCode::INTERNAL_SERVER_ERROR, format!("pxe-background task failed: {e}"), ) .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")), // No-cache so a freshly uploaded logo paints on the next // boot without a stale composite lingering. ( header::CACHE_CONTROL, HeaderValue::from_static("no-cache, max-age=0"), ), ], png, ) .into_response() } async fn ui_loader() -> Response { ( [ ( header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml"), ), (header::CACHE_CONTROL, ASSET_CACHE_CONTROL), ], 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: Result, axum::extract::rejection::ExtensionRejection>, 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. // (axum 0.8: `Result` is the optional-extractor form.) let peer_ip = peer.ok().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: Result, axum::extract::rejection::ExtensionRejection>, AxumPath(filename): AxumPath, Query(p): Query, ) -> Response { // axum 0.8: `Result` is the optional-extractor form. let peer_ip = peer.ok().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) => { // 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 // 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.clone(), ip: peer_ip, target_id: entry.id.clone(), target_title: format!("{} — {}", iso.filename, entry.title), }); // v0.5.0: fire-and-forget notification on the // canonical "a machine is imaging" moment. let who = mac_normalized .clone() .or_else(|| peer_ip.map(|ip| ip.to_string())) .unwrap_or_else(|| "an unknown client".into()); spawn_notify( &state, "PXE boot started", &format!("{who} started booting {} ({}).", iso.filename, entry.title), ); // v0.5.2: if this MAC has a deployment profile with // an unattended answer file selected (via a host // pin or queue Profile), inject the right kernel // arg so the install runs unattended. let unattended_args = mac_normalized.as_deref().and_then(|m| { resolve_profile(&state, m).and_then(|p| { p.unattended_file .as_deref() .and_then(|fid| state.unattended.get(fid)) .and_then(|meta| { build_unattended_args(base, &meta, Some(m), &p) }) }) }); return text_plain(render_entry( entry, &settings, base, unattended_args.as_deref(), )); } } } 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(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), }; ( [ ( 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); // v0.4.65: SMB-sourced ISOs have no on-disk path — they're // streamed live from the remote share via `smbclient`. We look // up the meta first to decide whether to take the path-based // local route or the subprocess-based SMB route. let Some(meta) = state.iso_store.get(id) else { return (StatusCode::NOT_FOUND, "no such iso").into_response(); }; match &meta.source { IsoSource::Local => { // `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 { Ok(r) => r, Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), } } IsoSource::Smb { share_id, relative_path, } => { // Range requests aren't supported for SMB sources in // v0.4.65 — smbclient's CLI can't seek mid-stream. iPXE // chain loading and ISO sanboot do whole-file sequential // reads, so this works in practice. A 416 here lets the // client fall back to a full GET if it tried a range. if headers.get(header::RANGE).is_some() { return Response::builder() .status(StatusCode::RANGE_NOT_SATISFIABLE) .header( header::CONTENT_RANGE, format!("bytes */{}", meta.size_bytes), ) .body(Body::empty()) .unwrap(); } match state.smb_shares.stream_iso(share_id, relative_path).await { Ok(stream) => { let reader = stream.stdout; let body_stream = tokio_util::io::ReaderStream::new(reader); Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "application/octet-stream") .header(header::CONTENT_LENGTH, meta.size_bytes) // Tell intermediaries we don't support // ranges on this resource; saves them from // even trying. .header(header::ACCEPT_RANGES, "none") .body(Body::from_stream(body_stream)) .unwrap() } Err(e) => (StatusCode::BAD_GATEWAY, format!("smb stream: {e}")).into_response(), } } IsoSource::Nfs { share_id, relative_path, } => { // v0.4.67: NFS sources support Range requests because // NFSv3 READ3 takes an explicit offset. We resolve the // requested byte range here and pass start/len down to // the streamer which seeks into the file via READ3. let total = meta.size_bytes; let range = match parse_range(headers.get(header::RANGE), total) { Some(triple) => triple, None if headers.get(header::RANGE).is_some() => { return Response::builder() .status(StatusCode::RANGE_NOT_SATISFIABLE) .header(header::CONTENT_RANGE, format!("bytes */{total}")) .body(Body::empty()) .unwrap(); } // No Range header — serve the whole file. None => (0, total.saturating_sub(1), false), }; let (start, end, partial) = range; let len = if total == 0 { 0 } else { end - start + 1 }; let max_len = if total == 0 { None } else { Some(len) }; match state .nfs_shares .stream_iso(share_id, relative_path, start, max_len) .await { Ok(stream) => { 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}"), ); } builder.body(body).unwrap() } Err(e) => (StatusCode::BAD_GATEWAY, format!("nfs stream: {e}")).into_response(), } } IsoSource::Sftp { share_id, relative_path, } => { // v0.5.5: SFTP sources support Range requests because SFTP // opens a seekable file handle (seek to offset, then bounded // reads). Identical handling to the NFS arm above. let total = meta.size_bytes; let range = match parse_range(headers.get(header::RANGE), total) { Some(triple) => triple, None if headers.get(header::RANGE).is_some() => { return Response::builder() .status(StatusCode::RANGE_NOT_SATISFIABLE) .header(header::CONTENT_RANGE, format!("bytes */{total}")) .body(Body::empty()) .unwrap(); } // No Range header — serve the whole file. None => (0, total.saturating_sub(1), false), }; let (start, end, partial) = range; let len = if total == 0 { 0 } else { end - start + 1 }; let max_len = if total == 0 { None } else { Some(len) }; match state .sftp_shares .stream_iso(share_id, relative_path, start, max_len) .await { Ok(stream) => { 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}"), ); } builder.body(body).unwrap() } Err(e) => (StatusCode::BAD_GATEWAY, format!("sftp stream: {e}")).into_response(), } } } } async fn iso_file( State(state): State, AxumPath((id, path)): AxumPath<(String, String)>, ) -> Response { // In-ISO file extraction is only supported for local ISOs — it // needs random-access reads into the ISO9660 directory tree, which // smbclient's whole-file streaming can't do efficiently. SMB- // sourced ISOs use the raw streaming endpoint above instead. 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)); } } // 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::() else { return full; }; let end = if end_s.trim().is_empty() { total.saturating_sub(1) } else if let Ok(e) = end_s.trim().parse::() { e } else { return full; }; 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, AxumPath(slot): AxumPath, mut multipart: Multipart, ) -> Response { let Some(slot) = LogoSlot::parse(&slot) else { return ( StatusCode::BAD_REQUEST, "unknown logo slot; expected light, dark, or client", ) .into_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(); }; // The PXE "client" logo is rasterized for the boot screen; an SVG // there can't be composited, so steer operators to a raster. if slot == LogoSlot::Client && mime == "image/svg+xml" { return ( StatusCode::BAD_REQUEST, "the client (PXE) logo must be a raster image (PNG/JPEG/WebP/GIF); SVG can't be painted on the boot screen", ) .into_response(); } match state.branding.set_logo(slot, &mime, ext, &bytes) { Ok(filename) => { return ( StatusCode::OK, Json(json!({ "slot": slot.as_str(), "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, AxumPath(slot): AxumPath, ) -> Result { let Some(slot) = LogoSlot::parse(&slot) else { return Ok((StatusCode::BAD_REQUEST, "unknown logo slot").into_response()); }; state.branding.clear_logo(slot)?; Ok(StatusCode::NO_CONTENT.into_response()) } // ─── Unattended answer files (v0.5.2) ────────────────────────────────────── // // Management (list/upload/delete) is gated behind the auth middleware. // *Serving* the files to the booting installer is the public // `/unattended/*` route pair below — the installer has no session. async fn api_unattended_list(State(state): State) -> Json { Json(json!({ "files": state.unattended.list() })) } async fn api_unattended_upload( State(state): State, mut multipart: Multipart, ) -> Result { // 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" { continue; } let filename = field.file_name().map(str::to_string).unwrap_or_default(); if filename.trim().is_empty() { return Ok((StatusCode::BAD_REQUEST, "missing filename on upload").into_response()); } let bytes = match field.bytes().await { Ok(b) => b, 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()); } Ok((StatusCode::BAD_REQUEST, "no 'file' part").into_response()) } async fn api_unattended_delete( State(state): State, AxumPath(id): AxumPath, ) -> StatusCode { if state.unattended.remove(&id).await { StatusCode::NO_CONTENT } else { StatusCode::NOT_FOUND } } #[derive(Debug, Deserialize)] struct UnattendedServeQuery { #[serde(default)] mac: Option, #[serde(default)] hostname: Option, #[serde(default)] ip: Option, } /// Public: serve a Kickstart/Preseed/answer file with `{{HOSTNAME}}` / /// `{{IP}}` / `{{MAC}}` substituted from the query string. Returns /// `text/plain` so installers (anaconda, debian-installer, Windows setup /// fetching over HTTP) read it verbatim. async fn serve_unattended( State(state): State, AxumPath(id): AxumPath, Query(q): Query, ) -> Response { let Ok(bytes) = state.unattended.read(&id).await else { return (StatusCode::NOT_FOUND, "no such unattended file").into_response(); }; let content = String::from_utf8_lossy(&bytes); let rendered = render_template( &content, q.mac.as_deref(), q.hostname.as_deref(), q.ip.as_deref(), ); text_plain(rendered) } /// Public: cloud-init NoCloud seed directory for Ubuntu autoinstall. The /// kernel arg points iPXE/cloud-init at `…///`; cloud-init then /// fetches `user-data`, `meta-data`, (and `vendor-data`). `` /// base64url-encodes the per-host hostname/ip/mac so they survive the /// seedfrom URL (which can't carry a query string). async fn serve_unattended_seed( State(state): State, AxumPath((id, ctx, sub)): AxumPath<(String, String, String)>, ) -> Response { let (mac, hostname, ip) = decode_seed_ctx(&ctx); match sub.as_str() { "user-data" => { let Ok(bytes) = state.unattended.read(&id).await else { return (StatusCode::NOT_FOUND, "no such unattended file").into_response(); }; let content = String::from_utf8_lossy(&bytes); let rendered = render_template(&content, mac.as_deref(), hostname.as_deref(), ip.as_deref()); text_plain(rendered) } "meta-data" => { let host_line = hostname .as_deref() .map(|h| format!("local-hostname: {h}\n")) .unwrap_or_default(); text_plain(format!("instance-id: openpxe-{id}\n{host_line}")) } // cloud-init probes vendor-data too; an empty 200 keeps it quiet. "vendor-data" => text_plain(String::new()), _ => (StatusCode::NOT_FOUND, "unknown seed resource").into_response(), } } /// Resolve the deployment profile for a booting MAC: a host pin wins, else /// a queued device's Profile. `None` when neither carries one. fn resolve_profile(state: &AppState, mac: &str) -> Option { if let Some(b) = state.hosts.lookup(mac) { if !b.profile.is_empty() { return Some(b.profile); } } state.queue.profile_for_mac(mac) } /// Build the per-host unattended kernel arguments for a Linux entry. /// Returns `None` for Windows answer files / unclassified uploads (no /// kernel cmdline injection applies). fn build_unattended_args( base: &str, meta: &UnattendedMeta, mac: Option<&str>, profile: &DeployProfile, ) -> Option { let base = base.trim_end_matches('/'); let id = &meta.id; let host = profile.auto_hostname.as_deref(); let ip = profile.auto_ip.as_deref(); let query = build_query(&[("mac", mac), ("hostname", host), ("ip", ip)]); match meta.kind { UnattendedKind::Kickstart => Some(format!("inst.ks={base}/unattended/{id}{query}")), UnattendedKind::Preseed => { let mut s = format!("auto=true priority=critical url={base}/unattended/{id}{query}"); if let Some(h) = host { s.push_str(" hostname="); s.push_str(h); } Some(s) } UnattendedKind::Autoinstall => { let ctx = encode_seed_ctx(mac, host, ip); Some(format!( "autoinstall ds=nocloud-net;s={base}/unattended/{id}/{ctx}/" )) } UnattendedKind::AnswerFile | UnattendedKind::Unknown => None, } } /// Build a `?k=v&…` query string from present key/value pairs, percent- /// encoding the values. Empty when nothing is present. fn build_query(pairs: &[(&str, Option<&str>)]) -> String { use std::fmt::Write as _; let mut out = String::new(); for (k, v) in pairs { if let Some(val) = v { out.push(if out.is_empty() { '?' } else { '&' }); let _ = write!(out, "{k}={}", pct_encode(val)); } } 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. fn encode_seed_ctx(mac: Option<&str>, hostname: Option<&str>, ip: Option<&str>) -> String { use base64::Engine as _; let raw = format!( "{}\n{}\n{}", hostname.unwrap_or(""), ip.unwrap_or(""), mac.unwrap_or("") ); base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes()) } /// Inverse of [`encode_seed_ctx`]; returns `(mac, hostname, ip)`. A bad /// or empty segment yields all-`None` so the seed still serves (just /// without per-host substitution). fn decode_seed_ctx(ctx: &str) -> (Option, Option, Option) { use base64::Engine as _; let Ok(bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(ctx.as_bytes()) else { return (None, None, None); }; let s = String::from_utf8_lossy(&bytes).into_owned(); let mut it = s.splitn(3, '\n'); let clean = |v: Option<&str>| v.map(str::to_string).filter(|x| !x.is_empty()); let hostname = clean(it.next()); let ip = clean(it.next()); let mac = clean(it.next()); (mac, hostname, ip) } // ─── 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": "SMB shares", "endpoints": [ {"method": "GET", "path": "/api/smb-shares", "summary": "List configured SMB shares with connection state and iso counts."}, {"method": "POST", "path": "/api/smb-shares", "summary": "Register an SMB share. Body: { server, share, guest, username?, password?, port? }."}, {"method": "DELETE", "path": "/api/smb-shares/{id}", "summary": "Forget a share and drop its entries from the ISO store."}, {"method": "POST", "path": "/api/smb-shares/{id}/scan", "summary": "Re-list a share for new ISOs."}, ], }, { "name": "NFS shares", "endpoints": [ {"method": "GET", "path": "/api/nfs-shares", "summary": "List configured NFSv3 shares with connection state and iso counts."}, {"method": "POST", "path": "/api/nfs-shares", "summary": "Register an NFSv3 share. Body: { server, export, port? }. Auth is AUTH_SYS only; access control is by client IP on the server side."}, {"method": "DELETE", "path": "/api/nfs-shares/{id}", "summary": "Forget a share and drop its entries from the ISO store."}, {"method": "POST", "path": "/api/nfs-shares/{id}/scan", "summary": "Re-list a share for new ISOs."}, ], }, { "name": "SFTP shares", "endpoints": [ {"method": "GET", "path": "/api/sftp-shares", "summary": "List configured SFTP-over-SSH shares with connection state and iso counts."}, {"method": "POST", "path": "/api/sftp-shares", "summary": "Register an SFTP share. Body: { server, export, username, port?, password? | private_key? + passphrase? }. The server's SSH host key is pinned trust-on-first-use."}, {"method": "DELETE", "path": "/api/sftp-shares/{id}", "summary": "Forget a share, drop its entries from the ISO store, and scrub its credentials file."}, {"method": "POST", "path": "/api/sftp-shares/{id}/scan", "summary": "Re-list a share for new 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/{slot}", "summary": "Upload a custom logo for a slot (light | dark | client). Multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB. The client slot is raster-only."}, {"method": "DELETE", "path": "/api/branding/logo/{slot}", "summary": "Remove the custom logo for a slot and revert to the bundled mark."}, {"method": "GET", "path": "/branding/pxe-logo", "summary": "Raster form of the operator's 'client' logo for the iPXE menu's `console --picture`. Default background when unset/SVG."}, {"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": "Unattended answer files", "endpoints": [ {"method": "GET", "path": "/api/unattended", "summary": "List uploaded answer files (Kickstart / Preseed / Autoinstall / Windows answer file)."}, {"method": "POST", "path": "/api/unattended", "summary": "Upload an answer file (multipart 'file', .ks/.cfg/.seed/.yaml/.yml/.xml/user-data, up to 1 MB)."}, {"method": "DELETE", "path": "/api/unattended/{id}", "summary": "Delete an uploaded answer file."}, {"method": "GET", "path": "/unattended/{id}", "summary": "Public: serve an answer file with {{HOSTNAME}}/{{IP}}/{{MAC}} substituted from the query string."}, ], }, { "name": "Queued Deployment", "endpoints": [ {"method": "GET", "path": "/api/queue", "summary": "List queue entries (waiting + assigned, with any deployment profile)."}, {"method": "POST", "path": "/api/queue/assign", "summary": "Assign a target image to queued clients. Body: { target, entry_ids }."}, {"method": "PUT", "path": "/api/queue/{entry_id}/profile", "summary": "Set a queued device's deployment profile. Body: { auto_hostname?, auto_ip?, unattended_file? }."}, {"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, auto_hostname?, auto_ip?, unattended_file? }."}, {"method": "DELETE", "path": "/api/hosts/{mac}", "summary": "Remove a binding."}, {"method": "POST", "path": "/api/hosts/{mac}/wol", "summary": "Send a Wake-on-LAN magic packet to a bound MAC (limited + subnet broadcast)."}, {"method": "GET", "path": "/api/boot-log", "summary": "Ring of recent boot events (timestamp, mac, ip, target)."}, ], }, { "name": "Notifications & updates", "endpoints": [ {"method": "GET", "path": "/api/notify", "summary": "Current notification config (SMTP password redacted)."}, {"method": "PUT", "path": "/api/notify", "summary": "Replace notification config. Body: { enabled, kind, webhook_url, smtp_* }."}, {"method": "POST", "path": "/api/notify/test", "summary": "Send a test notification using the saved config."}, {"method": "GET", "path": "/api/updates/check", "summary": "Compare the running version against the latest Gitea release."}, ], }, { "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() })) } /// 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, settings: Settings, smb: Option, smb_share_count: usize, smb_share_reachable: usize, nfs_share_count: usize, nfs_share_reachable: usize, sftp_share_count: usize, sftp_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) -> Json { 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 // sums both so operators see a single "reachable shares" number. let smb_shares = state.smb_shares.list(); let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count(); let nfs_shares = state.nfs_shares.list(); let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count(); // v0.5.5: SFTP shares fold into the same "reachable shares" tile. let sftp_shares = state.sftp_shares.list(); let sftp_reachable = sftp_shares.iter().filter(|m| m.reachable).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((smb_reachable + nfs_reachable + sftp_reachable) 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(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, // v0.5.5: SFTP share counts, summed into the same dashboard tile. sftp_share_count: sftp_shares.len(), sftp_share_reachable: sftp_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, 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) -> 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" ); // Carry `?mac=` so the per-entry handler can resolve this // device's deployment profile (auto hostname/IP + unattended // file) and inject the unattended kernel args, mirroring the // pinned-host path. let qmac = g.mac.clone(); text_plain(format!( "#!ipxe\n\ echo Queue assignment received: {target}\n\ chain {base}/boot/{target}.ipxe?mac={qmac} || 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_set_profile( State(state): State, AxumPath(entry_id): AxumPath, Json(body): Json, ) -> Response { let profile = body.normalized(); if let Err(msg) = validate_profile(&state, &profile) { return (StatusCode::BAD_REQUEST, msg).into_response(); } match state.queue.set_profile(&entry_id, profile) { Some(entry) => (StatusCode::OK, Json(entry)).into_response(), None => (StatusCode::NOT_FOUND, "no such queue entry").into_response(), } } 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, } } // ─── SMB share API (v0.4.65) ─────────────────────────────────────────────── // // Replaces the NFS share manager from v0.4.64. The wire shape is similar // — a {shares: [...]} list, a POST that returns either the share or a // structured {error, stderr, hint} body — so the UI can render both the // same way. async fn api_smb_shares_list(State(state): State) -> Json { Json(json!({ "shares": state.smb_shares.list() })) } async fn api_smb_shares_add( State(state): State, Json(req): Json, ) -> Response { match state.smb_shares.add(req).await { Ok(s) => (StatusCode::CREATED, Json(s)).into_response(), Err(err) => (StatusCode::BAD_REQUEST, Json(err)).into_response(), } } async fn api_smb_shares_remove( State(state): State, AxumPath(id): AxumPath, ) -> Response { match state.smb_shares.remove(&id).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), } } async fn api_smb_shares_scan( State(state): State, AxumPath(id): AxumPath, ) -> Response { match state.smb_shares.rescan(&id).await { Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(), Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(), } } // ─── NFS share API (v0.4.67) ─────────────────────────────────────────────── // // Parallel to the SMB shares API. The pure-Rust NFSv3 client // (`nfs3_client`) gives us in-process listing and streaming, no // subprocess. Unlike SMB, NFS-sourced ISOs support HTTP Range // requests — NFSv3 READ3 takes an explicit offset. async fn api_nfs_shares_list(State(state): State) -> Json { Json(json!({ "shares": state.nfs_shares.list() })) } async fn api_nfs_shares_add( State(state): State, Json(req): Json, ) -> Response { match state.nfs_shares.add(req).await { Ok(s) => (StatusCode::CREATED, Json(s)).into_response(), Err(err) => (StatusCode::BAD_REQUEST, Json(err)).into_response(), } } async fn api_nfs_shares_remove( State(state): State, AxumPath(id): AxumPath, ) -> Response { match state.nfs_shares.remove(&id).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), } } async fn api_nfs_shares_scan( State(state): State, AxumPath(id): AxumPath, ) -> Response { match state.nfs_shares.rescan(&id).await { Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(), Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(), } } // ─── SFTP share API (v0.5.5) ─────────────────────────────────────────────── // // Parallel to the NFS shares API. The pure-Rust `russh` + `russh-sftp` // client gives us in-process listing and streaming, no subprocess. Like // NFS (and unlike SMB), SFTP-sourced ISOs support HTTP Range requests — // SFTP opens a seekable file handle. Auth is password OR SSH private // key; the server's host key is pinned trust-on-first-use. async fn api_sftp_shares_list(State(state): State) -> Json { Json(json!({ "shares": state.sftp_shares.list() })) } async fn api_sftp_shares_add( State(state): State, Json(req): Json, ) -> Response { match state.sftp_shares.add(req).await { Ok(s) => (StatusCode::CREATED, Json(s)).into_response(), Err(err) => (StatusCode::BAD_REQUEST, Json(err)).into_response(), } } async fn api_sftp_shares_remove( State(state): State, AxumPath(id): AxumPath, ) -> Response { match state.sftp_shares.remove(&id).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), } } async fn api_sftp_shares_scan( State(state): State, AxumPath(id): AxumPath, ) -> Response { match state.sftp_shares.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, /// v0.5.2: optional unattended-install profile. Flattened so the /// front-end posts `auto_hostname` / `auto_ip` / `unattended_file` /// at the top level alongside mac/target/label. #[serde(default, flatten)] profile: DeployProfile, } 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 profile = body.profile.normalized(); if let Err(msg) = validate_profile(&state, &profile) { return (StatusCode::BAD_REQUEST, msg).into_response(); } let binding = state.hosts.upsert(mac, target, body.label.trim(), profile); (StatusCode::CREATED, Json(binding)).into_response() } /// Shared validation for a deployment profile (host pin + queue profile): /// the referenced unattended file must exist, and a supplied IP must /// parse. Hostname is free-form (installers vary), so we only length-cap /// it (done in `DeployProfile::normalized`). fn validate_profile(state: &AppState, profile: &DeployProfile) -> Result<(), String> { if let Some(id) = profile.unattended_file.as_deref() { if state.unattended.get(id).is_none() { return Err(format!("unknown unattended file: {id}")); } } if let Some(ip) = profile.auto_ip.as_deref() { if ip.parse::().is_err() { return Err(format!("auto_ip is not a valid IP address: {ip}")); } } Ok(()) } async fn api_hosts_remove( State(state): State, AxumPath(mac): AxumPath, ) -> StatusCode { if state.hosts.remove(&mac) { StatusCode::NO_CONTENT } else { StatusCode::NOT_FOUND } } /// v0.5.0: Wake-on-LAN a bound host. Sends a magic packet to the limited /// broadcast (255.255.255.255) and the server's own subnet broadcast /// (computed from the advertised IP + detected mask), which covers the /// common "same VLAN as OpenPXE" case with zero network config. We only /// wake MACs that are actually bound — keeps this from being an open /// "spray packets at any MAC" endpoint. async fn api_hosts_wol(State(state): State, AxumPath(mac): AxumPath) -> Response { if state.hosts.lookup(&mac).is_none() { return ( StatusCode::NOT_FOUND, "no host binding for that MAC — bind it first", ) .into_response(); } // Compute the server's subnet broadcast from the advertised IP + // detected mask so the packet reaches the right VLAN even if the // limited broadcast is filtered. Best-effort: skip if either won't // parse. let server_ip = state .public_base_url .strip_prefix("http://") .unwrap_or(&state.public_base_url) .split(':') .next() .unwrap_or("") .parse::(); let mask = state.subnet_mask.parse::(); let mut broadcasts = Vec::new(); if let (Ok(ip), Ok(m)) = (server_ip, mask) { broadcasts.push(wol::subnet_broadcast(ip, m)); } // The send is a blocking std UDP call; push it off the async // executor. let mac_owned = mac.clone(); let result = tokio::task::spawn_blocking(move || wol::wake(&mac_owned, &broadcasts)).await; match result { Ok(Ok(n)) => { // Fire-and-forget notification — nice "someone woke a box" // signal, never blocks the response. spawn_notify( &state, "Wake-on-LAN sent", &format!("OpenPXE sent a Wake-on-LAN magic packet to {mac}."), ); Json(json!({ "ok": true, "broadcasts": n })).into_response() } Ok(Err(e)) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(), Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, format!("wol task failed: {e}"), ) .into_response(), } } // ─── Notifications (v0.5.0) ─────────────────────────────────────────────── async fn api_notify_get(State(state): State) -> Json { // Redact the SMTP password before it leaves the process. Json(state.notify.snapshot().redacted()) } async fn api_notify_put(State(state): State, Json(cfg): Json) -> Response { match state.notify.replace(cfg) { Ok(saved) => (StatusCode::OK, Json(saved.redacted())).into_response(), Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(), } } /// Send a test notification using the *currently saved* config (not the /// request body) so the operator validates exactly what's persisted. async fn api_notify_test(State(state): State) -> Response { let cfg = state.notify.snapshot(); match crate::notify::send( &cfg, "OpenPXE test notification", "If you're reading this, OpenPXE notifications are wired up correctly. \ This is a test from the Advanced settings tab.", ) .await { Ok(()) => Json(json!({ "ok": true })).into_response(), Err(e) => (StatusCode::BAD_GATEWAY, e).into_response(), } } // ─── Update check (v0.5.0) ──────────────────────────────────────────────── /// Query the project's Gitea releases API for the latest published tag /// and compare it to the running version. This is the only outbound /// call OpenPXE makes that isn't operator-initiated data movement, and /// it's strictly on-demand (the About tab's "Check for updates" button) /// — never a background poll, keeping the air-gapped promise intact. async fn api_updates_check() -> Response { let current = env!("CARGO_PKG_VERSION"); let Some(api) = gitea_releases_api_url() else { return Json(json!({ "current": current, "error": "repository URL not configured at build time", })) .into_response(); }; let client = match reqwest::Client::builder() .timeout(std::time::Duration::from_secs(8)) .user_agent(concat!("OpenPXE/", env!("CARGO_PKG_VERSION"))) .build() { Ok(c) => c, Err(e) => { return Json(json!({ "current": current, "error": format!("client: {e}") })) .into_response(); } }; match client.get(&api).send().await { Ok(resp) if resp.status().is_success() => { let body: serde_json::Value = resp.json().await.unwrap_or(json!({})); let latest_tag = body .get("tag_name") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let html_url = body .get("html_url") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let update_available = version_is_newer(latest_tag.trim_start_matches('v'), current); Json(json!({ "current": current, "latest": latest_tag, "update_available": update_available, "html_url": html_url, })) .into_response() } Ok(resp) => Json(json!({ "current": current, "error": format!("releases API returned HTTP {}", resp.status()), })) .into_response(), Err(e) => Json(json!({ "current": current, "error": format!("could not reach the releases API: {e}"), })) .into_response(), } } /// Derive the Gitea `releases/latest` API URL from the compile-time /// repository URL (`https://host/owner/repo`). fn gitea_releases_api_url() -> Option { let repo = option_env!("CARGO_PKG_REPOSITORY").unwrap_or(""); let rest = repo .strip_prefix("https://") .or_else(|| repo.strip_prefix("http://"))?; let mut parts = rest.trim_end_matches('/').splitn(3, '/'); let host = parts.next()?; let owner = parts.next()?; let name = parts.next()?; if host.is_empty() || owner.is_empty() || name.is_empty() { return None; } Some(format!( "https://{host}/api/v1/repos/{owner}/{name}/releases/latest" )) } /// Compare two dotted numeric versions; `true` when `latest` is strictly /// newer than `current`. Non-numeric / malformed parts compare as 0, so /// a garbage tag never falsely reports an update. fn version_is_newer(latest: &str, current: &str) -> bool { fn parts(v: &str) -> Vec { v.split('.') .map(|p| { p.chars() .take_while(char::is_ascii_digit) .collect::() }) .map(|s| s.parse::().unwrap_or(0)) .collect() } let (l, c) = (parts(latest), parts(current)); for i in 0..l.len().max(c.len()) { let lv = l.get(i).copied().unwrap_or(0); let cv = c.get(i).copied().unwrap_or(0); if lv != cv { return lv > cv; } } false } /// Fire a notification on a detached task. Never blocks the caller and /// never surfaces an error — boot/WoL paths must not hinge on a webhook. fn spawn_notify(state: &AppState, subject: &str, body: &str) { let cfg = state.notify.snapshot(); if !cfg.is_usable() { return; } let subject = subject.to_string(); let body = body.to_string(); tokio::spawn(async move { if let Err(e) = crate::notify::send(&cfg, &subject, &body).await { tracing::warn!(target: "openpxe::notify", "notification send failed: {e}"); } }); } // ─── 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); // v0.4.67: gauge tracks reachable external-storage shares // across both protocols (SMB userspace + NFSv3 in-process). let smb_ok = state .smb_shares .list() .iter() .filter(|m| m.reachable) .count(); let nfs_ok = state .nfs_shares .list() .iter() .filter(|m| m.reachable) .count(); // v0.5.5: SFTP shares fold into the same reachable-shares gauge. let sftp_ok = state .sftp_shares .list() .iter() .filter(|m| m.reachable) .count(); state .metrics .set_nfs_active((smb_ok + nfs_ok + sftp_ok) 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::*; fn meta(kind: UnattendedKind) -> UnattendedMeta { UnattendedMeta { id: "ks1".into(), filename: "f".into(), kind, size_bytes: 0, uploaded_at: time::OffsetDateTime::UNIX_EPOCH, } } #[test] fn unattended_kickstart_arg_carries_query() { let p = DeployProfile { auto_hostname: Some("node7".into()), auto_ip: Some("10.0.0.7".into()), unattended_file: Some("ks1".into()), }; let a = build_unattended_args( "http://h", &meta(UnattendedKind::Kickstart), Some("aa:bb:cc:dd:ee:ff"), &p, ) .unwrap(); assert!(a.starts_with("inst.ks=http://h/unattended/ks1?"), "{a}"); assert!(a.contains("hostname=node7"), "{a}"); assert!(a.contains("ip=10.0.0.7"), "{a}"); // MAC colons are percent-encoded. assert!(a.contains("mac=aa%3Abb%3Acc%3Add%3Aee%3Aff"), "{a}"); } #[test] fn unattended_preseed_appends_hostname_kernel_arg() { let p = DeployProfile { auto_hostname: Some("deb1".into()), ..Default::default() }; let a = build_unattended_args("http://h/", &meta(UnattendedKind::Preseed), None, &p).unwrap(); assert!( a.starts_with("auto=true priority=critical url=http://h/unattended/ks1"), "{a}" ); assert!(a.ends_with(" hostname=deb1"), "{a}"); } #[test] fn unattended_autoinstall_uses_nocloud_seed_dir() { let p = DeployProfile { auto_hostname: Some("u1".into()), auto_ip: Some("10.1.1.5".into()), unattended_file: Some("ks1".into()), }; let a = build_unattended_args( "http://h", &meta(UnattendedKind::Autoinstall), Some("aa:bb"), &p, ) .unwrap(); assert!( a.starts_with("autoinstall ds=nocloud-net;s=http://h/unattended/ks1/"), "{a}" ); assert!(a.ends_with('/'), "seed URL must end with '/': {a}"); // The ctx segment round-trips back to the per-host values. let ctx = a.trim_end_matches('/').rsplit('/').next().unwrap(); let (mac, host, ip) = decode_seed_ctx(ctx); assert_eq!(mac.as_deref(), Some("aa:bb")); assert_eq!(host.as_deref(), Some("u1")); assert_eq!(ip.as_deref(), Some("10.1.1.5")); } #[test] fn windows_answer_file_is_not_injected() { let p = DeployProfile { unattended_file: Some("ks1".into()), ..Default::default() }; assert!( build_unattended_args("http://h", &meta(UnattendedKind::AnswerFile), None, &p) .is_none() ); } #[test] fn seed_ctx_empty_segment_decodes_to_none() { let ctx = encode_seed_ctx(None, None, None); let (m, h, i) = decode_seed_ctx(&ctx); assert!(m.is_none() && h.is_none() && i.is_none()); // Garbage decodes safely to all-None. let (m2, h2, i2) = decode_seed_ctx("!!!not-base64!!!"); assert!(m2.is_none() && h2.is_none() && i2.is_none()); } #[test] fn pct_encode_escapes_reserved() { assert_eq!(pct_encode("aa:bb cc"), "aa%3Abb%20cc"); assert_eq!(pct_encode("node-7.lab_1~"), "node-7.lab_1~"); } #[test] fn version_newer_detects_updates() { assert!(version_is_newer("0.5.1", "0.5.0")); assert!(version_is_newer("0.6.0", "0.5.9")); assert!(version_is_newer("1.0.0", "0.9.9")); assert!(!version_is_newer("0.5.0", "0.5.0")); assert!(!version_is_newer("0.4.69", "0.5.0")); // A garbage / empty tag must never falsely report an update. assert!(!version_is_newer("", "0.5.0")); assert!(!version_is_newer("not-a-version", "0.5.0")); } #[test] fn gitea_api_url_derives_from_repo() { // The crate inherits the workspace `repository`, so // CARGO_PKG_REPOSITORY is populated and the update check has a // real URL to hit (regressed once when the inherit was missing). let u = gitea_releases_api_url().expect("repository must be configured at build time"); assert!(u.contains("/api/v1/repos/"), "got: {u}"); assert!(u.ends_with("/releases/latest"), "got: {u}"); } #[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); } }