Files
OpenPXE/crates/http-api/src/app.rs
T
Miles WardandClaude Opus 4.8 7f25bb681c v0.6.3: russh 0.61 security bump (CVE batch) + bergshamra 0.5 + axum 0.8
Security-driven dependency release.

- russh =0.55.0 (pinned) -> 0.61.2: closes the advisory batch reachable
  from our SFTP *client* path — unbounded/allocation-first packet
  parsing (CVE-2026-48110, CVE-2026-46702, CVE-2026-46673, HIGH) plus
  CVE-2026-48107 in client auth. A malicious or compromised SFTP server
  an operator pointed us at could previously OOM the PXE server. Also
  drops mlock on non-secret buffers (~21% SSH throughput upstream) —
  directly in the remote-share ISO streaming path. ring backend kept;
  zero code changes needed in sftp_share.rs.
- bergshamra 0.4 -> 0.5.1: the pin's blocking condition (stable
  RustCrypto generation, pkcs8 0.11) is now met upstream, so the
  =0.55.0 pin is deleted and its comment rewritten as history. 0.5 is
  secure-by-default for DSig (flags we already set explicitly) and
  fixes an XML-Enc DerivedKey fallthrough.
- axum 0.7 -> 0.8.9: route captures /:id -> {id} across the router and
  the /api/docs listing; ConnectInfo optional extraction moves to the
  Result form. Gains the HEAD content-length fix (iPXE/sanboot clients
  probe with HEAD before Range requests) and puts us back on the
  maintained line.

Validation: clippy clean, fmt clean, all 272 workspace tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-09 19:47:13 -04:00

3089 lines
127 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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/<id>.ipxe`| Per-entry boot script |
//! | `/ipxe/<file>` | Bundled iPXE binaries + wimboot + memtest |
//! | `/iso/<id>.iso` | Raw ISO with Range support |
//! | `/iso/<id>/*` | 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 (`…/<ctx>/user-data` + `/meta-data`), where `<ctx>`
// 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<AppState>) -> Json<SsoConfig> {
// 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<AppState>,
Json(body): Json<SsoConfig>,
) -> Result<Json<SsoConfig>, AppError> {
// v0.5.4: `?` + AppError centralizes Invalid→400 / _→500.
Ok(Json(state.sso.replace(body)?))
}
// ─── UI ────────────────────────────────────────────────────────────────────
async fn index(State(state): State<AppState>) -> 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=<version>` 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<String>,
}
async fn ui_logo(State(state): State<AppState>, Query(q): Query<LogoQuery>) -> 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<AppState>) -> Response {
// The composite is a pure function of the uploaded logo, so the
// encoded PNG is cached keyed on the branding revision — an upload
// or clear bumps the rev and invalidates it. The response headers
// stay `no-cache` (clients must refetch); only the server-side
// ~50-200 ms decode/compose/encode is skipped per boot.
let rev = state.branding.logo_rev();
let cached = state
.pxe_bg_cache
.lock()
.as_ref()
.filter(|(r, _)| *r == rev)
.map(|(_, png)| png.clone());
if let Some(png) = cached {
return pxe_png_response(png);
}
// Resolve the operator's raster upload, if any and if it's a format
// iPXE/our compositor can consume. SVG (or a missing/unreadable
// file) yields `None`, which composes the default background.
let raster: Option<Vec<u8>> = 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<AppState>,
peer: Result<ConnectInfo<SocketAddr>, axum::extract::rejection::ExtensionRejection>,
Query(p): Query<BootMenuParams>,
) -> 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<T, Rejection>` 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/<id>.ipxe`. Both share the same
// `/boot/<name>` 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<String>,
}
#[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<String>,
/// 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<String>,
}
async fn boot_sub(
State(state): State<AppState>,
peer: Result<ConnectInfo<SocketAddr>, axum::extract::rejection::ExtensionRejection>,
AxumPath(filename): AxumPath<String>,
Query(p): Query<BootSubParams>,
) -> Response {
// axum 0.8: `Result<T, Rejection>` is the optional-extractor form.
let peer_ip = peer.ok().map(|c| c.0.ip());
// `/boot/<name>.ipxe` where `<name>` 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<String>) -> 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<AppState>,
AxumPath(filename): AxumPath<String>,
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<AppState>,
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<Response> {
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<Response> {
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::<u64>() {
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::<u64>() else {
return full;
};
let end = if end_s.trim().is_empty() {
total.saturating_sub(1)
} else if let Ok(e) = end_s.trim().parse::<u64>() {
e
} else {
return full;
};
if start >= total {
return None;
}
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<AppState>) -> Json<Vec<IsoMeta>> {
Json(state.iso_store.list())
}
async fn api_delete_iso(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> 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<String>,
}
async fn api_set_iso_password(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
Json(body): Json<SetPasswordBody>,
) -> 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<AppState>,
AxumPath(id): AxumPath<String>,
) -> 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<AppState>,
AxumPath(id): AxumPath<String>,
Json(body): Json<SetCategoryBody>,
) -> 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<AppState>) -> Json<serde_json::Value> {
// 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<AppState>,
AxumPath(slot): AxumPath<String>,
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<AppState>,
AxumPath(slot): AxumPath<String>,
) -> Result<Response, AppError> {
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<AppState>) -> Json<serde_json::Value> {
Json(json!({ "files": state.unattended.list() }))
}
async fn api_unattended_upload(
State(state): State<AppState>,
mut multipart: Multipart,
) -> Result<Response, AppError> {
// v0.5.4: the answer-file add() maps Invalid→400 / _→500 via `?`+AppError.
// The multipart-shape 400s (missing field/filename) stay explicit.
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
if name != "file" && name != "unattended" {
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<AppState>,
AxumPath(id): AxumPath<String>,
) -> StatusCode {
if state.unattended.remove(&id).await {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
#[derive(Debug, Deserialize)]
struct UnattendedServeQuery {
#[serde(default)]
mac: Option<String>,
#[serde(default)]
hostname: Option<String>,
#[serde(default)]
ip: Option<String>,
}
/// 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<AppState>,
AxumPath(id): AxumPath<String>,
Query(q): Query<UnattendedServeQuery>,
) -> 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 `…/<id>/<ctx>/`; cloud-init then
/// fetches `user-data`, `meta-data`, (and `vendor-data`). `<ctx>`
/// 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<AppState>,
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<DeployProfile> {
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<String> {
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<String>, Option<String>, Option<String>) {
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<serde_json::Value> {
// 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<AppState>, 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<u64>,
}
async fn api_upload_begin(
State(state): State<AppState>,
Json(body): Json<UploadBeginBody>,
) -> 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<AppState>,
AxumPath(upload_id): AxumPath<String>,
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<AppState>,
AxumPath(upload_id): AxumPath<String>,
) -> 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<u64> {
headers.get(name)?.to_str().ok()?.trim().parse::<u64>().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<AppState>) -> 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<AppState>) -> Json<serde_json::Value> {
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<String>,
settings: Settings,
smb: Option<SmbState>,
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<AppState>) -> Json<StatusResponse> {
let smb = state.smb.as_ref().map(|s| s.snapshot());
// v0.4.65+v0.4.67: external storage shares — SMB (userspace
// smbclient) and NFS (in-process nfs3_client). Dashboard tile
// 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<AppState>) -> Json<Settings> {
Json(state.settings.snapshot())
}
async fn api_put_settings(
State(state): State<AppState>,
Json(mut new): Json<Settings>,
) -> 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<AppState>) -> Json<serde_json::Value> {
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<String>,
}
/// Called by iPXE via `chain --replace`. We respond with a tiny iPXE
/// script that hard-loops on `/api/queue/poll/<id>`. iPXE keeps fetching
/// until poll returns an actual boot script.
async fn api_queue_join(
State(state): State<AppState>,
Query(p): Query<QueueJoinParams>,
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<AppState>,
AxumPath(entry_id): AxumPath<String>,
) -> 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/<id>.ipxe`.
target: String,
/// Queue entry ids to assign. Empty = assign to all currently queued clients.
entry_ids: Vec<String>,
}
async fn api_queue_assign(
State(state): State<AppState>,
Json(body): Json<QueueAssignBody>,
) -> Json<serde_json::Value> {
let ids = if body.entry_ids.is_empty() {
state
.queue
.list()
.into_iter()
.map(|g| g.id)
.collect::<Vec<_>>()
} 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<AppState>,
AxumPath(entry_id): AxumPath<String>,
Json(body): Json<DeployProfile>,
) -> 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<AppState>,
AxumPath(entry_id): AxumPath<String>,
) -> 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<AppState>) -> Json<serde_json::Value> {
Json(json!({ "shares": state.smb_shares.list() }))
}
async fn api_smb_shares_add(
State(state): State<AppState>,
Json(req): Json<SmbAddRequest>,
) -> 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<AppState>,
AxumPath(id): AxumPath<String>,
) -> 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<AppState>,
AxumPath(id): AxumPath<String>,
) -> 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<AppState>) -> Json<serde_json::Value> {
Json(json!({ "shares": state.nfs_shares.list() }))
}
async fn api_nfs_shares_add(
State(state): State<AppState>,
Json(req): Json<NfsAddRequest>,
) -> 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<AppState>,
AxumPath(id): AxumPath<String>,
) -> 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<AppState>,
AxumPath(id): AxumPath<String>,
) -> 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<AppState>) -> Json<serde_json::Value> {
Json(json!({ "shares": state.sftp_shares.list() }))
}
async fn api_sftp_shares_add(
State(state): State<AppState>,
Json(req): Json<SftpAddRequest>,
) -> 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<AppState>,
AxumPath(id): AxumPath<String>,
) -> 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<AppState>,
AxumPath(id): AxumPath<String>,
) -> 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<AppState>) -> Json<serde_json::Value> {
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<AppState>,
Json(body): Json<NetworkPut>,
) -> 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<AppState>) -> Json<serde_json::Value> {
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<AppState>,
Json(body): Json<HostsUpsertBody>,
) -> 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::<std::net::IpAddr>().is_err() {
return Err(format!("auto_ip is not a valid IP address: {ip}"));
}
}
Ok(())
}
async fn api_hosts_remove(
State(state): State<AppState>,
AxumPath(mac): AxumPath<String>,
) -> 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<AppState>, AxumPath(mac): AxumPath<String>) -> 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::<std::net::Ipv4Addr>();
let mask = state.subnet_mask.parse::<std::net::Ipv4Addr>();
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<AppState>) -> Json<NotifyConfig> {
// Redact the SMTP password before it leaves the process.
Json(state.notify.snapshot().redacted())
}
async fn api_notify_put(State(state): State<AppState>, Json(cfg): Json<NotifyConfig>) -> 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<AppState>) -> 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<String> {
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<u64> {
v.split('.')
.map(|p| {
p.chars()
.take_while(char::is_ascii_digit)
.collect::<String>()
})
.map(|s| s.parse::<u64>().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<AppState>) -> Json<serde_json::Value> {
Json(json!({ "events": state.boot_log.list() }))
}
// ─── Prometheus metrics ───────────────────────────────────────────────────
async fn api_metrics(State(state): State<AppState>) -> 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);
}
}