Files
OpenPXE/crates/http-api/src/app.rs
T
Miles WardandClaude Opus 4.8 5f98e6e03f v0.8.1: add ISO by URL, zero-touch admin bootstrap
Ease-of-use pass inspired by Bootimus (Dnsmasq-PXE is a manual dnsmasq
setup guide — nothing to adopt; OpenPXE already replaces that stack).

Add ISO by URL:
- New http-api `fetch` module: a small FetchJobs registry + a background
  streaming download (reqwest) that pipes a remote .iso through the same
  UploadHandle + introspection path as an upload, so a URL-fetched image
  classifies and gains boot entries identically. Progress is polled by the
  Storage view and rendered as rows, mirroring uploads.
- Routes POST/GET/DELETE /api/isos/fetch. http/https only; .iso-only
  filename derived from Content-Disposition / URL basename with path
  traversal stripped; 16 GiB cap; cancel; credential-stripped URL display.
  Operator-gated, no boot-time outbound — offline boot is untouched.
- Storage upload card gains an "Or add by URL" field with progress + cancel.

Zero-touch admin bootstrap:
- OPENPXE_ADMIN_USERNAME + OPENPXE_ADMIN_PASSWORD (or _PASSWORD_FILE for
  Docker/K8s secrets) auto-create the admin on first run, so a fresh
  container is usable with no setup wizard. Seeds the first run only — a
  lingering env var can't reset a rotated password.

Tests: URL parse / filename / Content-Disposition unit tests + a wiremock
end-to-end fetch-into-store integration test. clippy/fmt/node clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-16 15:56:27 -04:00

3424 lines
141 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::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::iso_fs;
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))
// v0.8.1: add ISO by URL — server-side streaming download + progress
// polling. Static `fetch` coexists with `{id}` above (matchit
// prioritizes the literal), same as `/api/queue/join` vs `{entry_id}`.
.route(
"/api/isos/fetch",
get(crate::fetch::api_iso_fetch_list).post(crate::fetch::api_iso_fetch_start),
)
.route(
"/api/isos/fetch/{id}",
delete(crate::fetch::api_iso_fetch_cancel),
)
.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))
// v0.8.0: operator API key surface (read current + regenerate).
// Gated by require_auth like the rest of /api/*; a logged-in
// operator or an x-api-key holder can read/rotate it.
.route("/api/api-key", get(auth_api::api_api_key_get))
.route(
"/api/api-key/regenerate",
post(auth_api::api_api_key_regenerate),
)
// 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))
// v0.7.0: ordered boot rules (MAC prefix / arch → target) + the
// boot-decision webhook. The UI saves the whole config at once
// because rule order is significant.
.route(
"/api/boot-rules",
get(api_boot_rules_get).put(api_boot_rules_put),
)
// 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(chain_script(base, &target, &bound_mac, "per-MAC binding"));
}
// v0.7.0 step 2: ordered boot rules (MAC prefix / arch).
let mac_norm = openpxe_core::normalize_mac(mac);
if let Some(target) = state.boot_rules.match_target(&mac_norm, p.arch.as_deref()) {
tracing::info!(
target: "openpxe::http",
mac = %mac_norm, target = %target, "boot rule matched"
);
record_pre_boot(&state, &isos, &mac_norm, peer_ip, &target);
return text_plain(chain_script(base, &target, &mac_norm, "boot rule"));
}
// v0.7.0 step 3: boot-decision webhook (fail-open — any error,
// timeout, or non-200 falls through to the menu so a dead
// automation endpoint can never block PXE for the network).
if let Some(url) = state.boot_rules.webhook_url() {
if let Some(target) = webhook_decide(&url, &mac_norm, p.arch.as_deref()).await {
tracing::info!(
target: "openpxe::http",
mac = %mac_norm, target = %target, "boot webhook decided"
);
record_pre_boot(&state, &isos, &mac_norm, peer_ip, &target);
return text_plain(chain_script(base, &target, &mac_norm, "boot webhook"));
}
}
}
text_plain(render_menu(&isos, &settings, base))
}
/// The short-circuit script all three decision sources (binding, rule,
/// webhook) emit: chain to the target's boot script, falling back to the
/// interactive menu so a stale target can't lock a client out.
fn chain_script(base: &str, target: &str, mac: &str, source: &str) -> String {
format!(
"#!ipxe\n\
echo OpenPXE: {source} -> {target}\n\
chain {base}/boot/{target}.ipxe?mac={mac} || chain {base}/boot.ipxe\n"
)
}
/// Pre-record a decision-driven boot into the Host log, mirroring what
/// the per-MAC binding path does: reserved `_xxx` targets are operator
/// conveniences, not imaging events, so they're skipped.
fn record_pre_boot(
state: &AppState,
isos: &[openpxe_iso_store::IsoMeta],
mac: &str,
peer_ip: Option<std::net::IpAddr>,
target: &str,
) {
if target.starts_with('_') {
return;
}
let title = lookup_entry_title(isos, target);
state.boot_log.record(&BootEvent {
timestamp: time::OffsetDateTime::now_utc(),
mac: Some(mac.to_string()),
ip: peer_ip,
target_id: target.to_string(),
target_title: title,
});
}
/// Ask the operator's boot-decision webhook for a target. `200` with
/// `{"target": "<id>"}` chains to that target; anything else (including
/// an empty target) means "no opinion". Two-second budget — a booting
/// machine is sitting at a black screen while this runs.
async fn webhook_decide(url: &str, mac: &str, arch: Option<&str>) -> Option<String> {
#[derive(Deserialize)]
struct Decision {
target: String,
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.build()
.ok()?;
let resp = match client
.get(url)
.query(&[("mac", mac), ("arch", arch.unwrap_or(""))])
.send()
.await
{
Ok(r) => r,
Err(e) => {
tracing::warn!(target: "openpxe::http", "boot webhook unreachable: {e}");
return None;
}
};
if !resp.status().is_success() {
return None;
}
let d: Decision = resp.json().await.ok()?;
let t = d.target.trim().to_string();
(!t.is_empty()).then_some(t)
}
/// 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>,
/// v0.7.0: client architecture (`ClientArch::as_str()` form), baked
/// literally into the chain URL by the DHCP proxy, which knows it
/// from option 93. Lets boot rules select on architecture. Absent on
/// chains rendered by older binaries — arch rules simply don't match.
arch: 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,
&state.boot_tokens,
)
})
})
});
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(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
if name.contains('/') || name.contains('\\') {
return (StatusCode::BAD_REQUEST, "invalid name").into_response();
}
// v0.7.0: when the whole Secure Boot chain rides HTTP (native UEFI
// HTTP Boot), GRUB resolves `$prefix` to this directory and fetches
// its config from here — rendered live, same as the TFTP path.
if name == "grub.cfg" || name.starts_with("grub.cfg-") {
return text_plain(crate::grub_script::render_grub_menu(
&state.iso_store.list(),
&state.public_base_url,
));
}
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 {
let Some(meta) = state.iso_store.get(&id) else {
return (StatusCode::NOT_FOUND, "no such iso").into_response();
};
let in_path = format!("/{path}");
match &meta.source {
IsoSource::Local => {
let Some(iso_path) = state.iso_store.local_path(&meta) else {
return (StatusCode::NOT_FOUND, "no such iso").into_response();
};
let p = iso_path.clone();
let loc = tokio::task::spawn_blocking(move || iso_fs::lookup_local(&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(),
}
}
// v0.7.4: remote ISOs serve in-ISO files too — the same ISO9660
// walk runs over NFS READ3 / SFTP seek-reads, then the located
// byte range streams through the share manager. This is what
// makes the verified kernel/initrd boot entries on share-hosted
// Linux ISOs actually bootable.
IsoSource::Nfs {
share_id,
relative_path,
} => {
match state
.nfs_shares
.locate_in_iso(share_id, relative_path, &in_path)
.await
{
Ok(Some(loc)) => {
match state
.nfs_shares
.stream_iso(share_id, relative_path, loc.offset, Some(loc.length))
.await
{
Ok(stream) => in_iso_stream_response(Body::from_stream(stream), loc.length),
Err(e) => {
(StatusCode::BAD_GATEWAY, format!("nfs stream: {e}")).into_response()
}
}
}
Ok(None) => (StatusCode::NOT_FOUND, "not found inside iso").into_response(),
Err(e) => (StatusCode::BAD_GATEWAY, format!("nfs lookup: {e}")).into_response(),
}
}
IsoSource::Sftp {
share_id,
relative_path,
} => {
match state
.sftp_shares
.locate_in_iso(share_id, relative_path, &in_path)
.await
{
Ok(Some(loc)) => {
match state
.sftp_shares
.stream_iso(share_id, relative_path, loc.offset, Some(loc.length))
.await
{
Ok(stream) => in_iso_stream_response(Body::from_stream(stream), loc.length),
Err(e) => {
(StatusCode::BAD_GATEWAY, format!("sftp stream: {e}")).into_response()
}
}
}
Ok(None) => (StatusCode::NOT_FOUND, "not found inside iso").into_response(),
Err(e) => (StatusCode::BAD_GATEWAY, format!("sftp lookup: {e}")).into_response(),
}
}
// smbclient streams sequentially — no seeks, no ISO9660 walk.
// SMB ISOs never emit kernel entries, so nothing requests this.
IsoSource::Smb { .. } => (
StatusCode::NOT_FOUND,
"in-ISO files are not available for SMB-sourced ISOs",
)
.into_response(),
}
}
/// 200 response wrapping an in-ISO byte-range stream from a share
/// manager. Content-Length is the located file's length — the stream is
/// already bounded to exactly that range.
fn in_iso_stream_response(body: Body, length: u64) -> Response {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(header::CONTENT_LENGTH, length)
.body(body)
.unwrap()
}
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>,
/// v0.7.0: short-lived access token minted into the generated URL.
#[serde(default)]
t: Option<String>,
}
/// v0.7.0: answer files routinely embed credentials, so once an admin
/// account exists they're only served to (a) the boot that the URL was
/// minted for — proven by the token OpenPXE put in that URL — or (b) a
/// logged-in operator (browser testing). Pre-setup installs stay open,
/// matching the auth middleware's bootstrap behavior.
fn unattended_access_allowed(
state: &AppState,
headers: &HeaderMap,
token: Option<&str>,
file_id: &str,
) -> bool {
if !state.admin.is_configured() {
return true;
}
if token.is_some_and(|t| state.boot_tokens.check(t, file_id)) {
return true;
}
crate::auth::session_authenticated(state, headers)
}
fn unattended_denied() -> Response {
(
StatusCode::UNAUTHORIZED,
"answer files require the boot-scoped token OpenPXE mints into \
generated URLs (or an operator session)",
)
.into_response()
}
/// 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. Token-gated since v0.7.0 — see
/// [`unattended_access_allowed`].
async fn serve_unattended(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
headers: HeaderMap,
Query(q): Query<UnattendedServeQuery>,
) -> Response {
if !unattended_access_allowed(&state, &headers, q.t.as_deref(), &id) {
return unattended_denied();
}
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)>,
headers: HeaderMap,
) -> Response {
let (mac, hostname, ip, token) = decode_seed_ctx(&ctx);
// v0.7.0: the seed ctx carries the access token (the seedfrom URL
// can't take a query string). Same gate as the flat answer-file
// route — see `unattended_access_allowed`.
if !unattended_access_allowed(&state, &headers, token.as_deref(), &id) {
return unattended_denied();
}
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(),
}
}
// ─── Boot rules (v0.7.0) ───────────────────────────────────────────────────
async fn api_boot_rules_get(State(state): State<AppState>) -> Json<openpxe_core::BootRulesConfig> {
Json(state.boot_rules.snapshot())
}
async fn api_boot_rules_put(
State(state): State<AppState>,
Json(cfg): Json<openpxe_core::BootRulesConfig>,
) -> StatusCode {
let n = cfg.rules.len();
state.boot_rules.replace(cfg);
tracing::info!(target: "openpxe::http", rules = n, "boot rules replaced");
StatusCode::NO_CONTENT
}
/// 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,
tokens: &openpxe_core::BootTokens,
) -> 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();
// v0.7.0: every generated answer-file URL carries a fresh boot-scoped
// token; the serving endpoint requires it. See `crate::auth` and
// `openpxe_core::boot_tokens` for the threat model (CVE-2026-0386-
// style credential harvesting from openly-served answer files).
let token = tokens.mint(id);
let query = build_query(&[
("mac", mac),
("hostname", host),
("ip", ip),
("t", Some(&token)),
]);
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, &token);
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, token)` into a single base64url path
/// segment for the cloud-init seed directory. Empty values become empty
/// fields. The access token rides in here (v0.7.0) because the
/// `seedfrom` URL can't carry a query string.
fn encode_seed_ctx(
mac: Option<&str>,
hostname: Option<&str>,
ip: Option<&str>,
token: &str,
) -> String {
use base64::Engine as _;
let raw = format!(
"{}\n{}\n{}\n{token}",
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, token)`.
/// A bad or empty segment yields all-`None`; a pre-v0.7.0 three-field
/// ctx decodes with `token: None` (and the gate then rejects it once an
/// admin exists — stale URLs are exactly what tokens invalidate).
fn decode_seed_ctx(
ctx: &str,
) -> (
Option<String>,
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, None);
};
let s = String::from_utf8_lossy(&bytes).into_owned();
let mut it = s.splitn(4, '\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());
let token = clean(it.next());
(mac, hostname, ip, token)
}
// ─── 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\" }."},
{"method": "POST", "path": "/api/isos/fetch",
"summary": "Add an ISO by URL. Body: { \"url\", \"filename\"? }. The server streams the .iso into storage and introspects it. Returns { fetch_id }."},
{"method": "GET", "path": "/api/isos/fetch",
"summary": "Poll URL-fetch progress — [{ id, filename, url, downloaded, total, state }]. Completed jobs are returned once."},
{"method": "DELETE", "path": "/api/isos/fetch/{id}",
"summary": "Cancel an in-flight URL fetch, or dismiss a finished/failed one."},
],
},
{
"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."},
{"method": "GET", "path": "/api/api-key",
"summary": "Return the operator API key + the header to send it in (x-api-key). That header authenticates API calls without a browser session — full operator access."},
{"method": "POST", "path": "/api/api-key/regenerate",
"summary": "Mint a fresh API key, invalidating the previous one immediately."},
],
},
{
"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": "GET", "path": "/api/boot-rules",
"summary": "Boot rules + decision-webhook config (v0.7.0)."},
{"method": "PUT", "path": "/api/boot-rules",
"summary": "Replace the whole boot-rules config (rules are ordered)."},
{"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,
"nic_link": state.nic_link,
"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 tokens = openpxe_core::BootTokens::new();
let a = build_unattended_args(
"http://h",
&meta(UnattendedKind::Kickstart),
Some("aa:bb:cc:dd:ee:ff"),
&p,
&tokens,
)
.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}");
// v0.7.0: a live access token rides in the generated URL.
let tok = a.rsplit("t=").next().unwrap();
assert!(tokens.check(tok, "ks1"), "minted token must be live: {a}");
}
#[test]
fn unattended_preseed_appends_hostname_kernel_arg() {
let p = DeployProfile {
auto_hostname: Some("deb1".into()),
..Default::default()
};
let tokens = openpxe_core::BootTokens::new();
let a = build_unattended_args(
"http://h/",
&meta(UnattendedKind::Preseed),
None,
&p,
&tokens,
)
.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 tokens = openpxe_core::BootTokens::new();
let a = build_unattended_args(
"http://h",
&meta(UnattendedKind::Autoinstall),
Some("aa:bb"),
&p,
&tokens,
)
.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, token) = 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"));
// v0.7.0: the ctx carries a live access token for the file.
assert!(tokens.check(token.as_deref().unwrap(), "ks1"));
}
#[test]
fn windows_answer_file_is_not_injected() {
let p = DeployProfile {
unattended_file: Some("ks1".into()),
..Default::default()
};
let tokens = openpxe_core::BootTokens::new();
assert!(build_unattended_args(
"http://h",
&meta(UnattendedKind::AnswerFile),
None,
&p,
&tokens
)
.is_none());
}
#[test]
fn seed_ctx_empty_segment_decodes_to_none() {
let ctx = encode_seed_ctx(None, None, None, "tok");
let (m, h, i, t) = decode_seed_ctx(&ctx);
assert!(m.is_none() && h.is_none() && i.is_none());
assert_eq!(t.as_deref(), Some("tok"));
// Garbage decodes safely to all-None.
let (m2, h2, i2, t2) = decode_seed_ctx("!!!not-base64!!!");
assert!(m2.is_none() && h2.is_none() && i2.is_none() && t2.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);
}
}