VMware UEFI / Casper boot fix:
- Linux cmdline for Debian/Ubuntu/Mint/Pop!_OS/elementary now uses the
canonical Casper `iso-url=` option and `ds=nocloud`, matching the
fix Bootimus shipped in v0.1.67. The previous
`boot=casper netboot=url url=… ip=dhcp ---` form booted fine on
bare-metal UEFI but hung at "cloud-init running" on VMware guests
because subiquity / cloud-init can't reach a metadata datasource
through PXE.
Static binary (matches Bootimus v0.1.70):
- Dockerfile build stage now compiles against
x86_64-unknown-linux-musl. The resulting /openpxe has no glibc
dependency at all; the runtime stage still ships Debian slim for the
samba/wimtools/nfs-common shellouts, but a future scratch/distroless
variant is now a one-line swap. Cuts a class of "GLIBC_2.39 not
found" surprises on older RHEL/Rocky hosts.
Forms auth (Sonarr/Radarr-style):
- New AdminStore in openpxe-core: single admin record persisted to
<work_dir>/auth.json, bcrypt-hashed credentials, rotation requires
current password.
- New SessionStore in openpxe-http-api: in-memory UUID-keyed sessions
with 24h sliding TTL, openpxe_session HttpOnly cookie.
- Endpoints: POST /api/setup (first-run), POST /api/login, POST
/api/logout, GET /api/me, PUT /api/me/credentials (rotates and
revokes every other session).
- Auth middleware gates /api/* once the admin is configured;
passes through entirely until then (tests + fresh installs ride this
path). Allowlists PXE-essential paths (/boot.ipxe, /iso/*, /ipxe/*,
/api/queue/join, /api/queue/poll/*) so iPXE clients still work
without a cookie they can't send.
- WebUI: first-run setup card, login card, logout chip in the sidebar
footer, Account card in Settings for rotating creds. Auth screen is
fully styled (centered narrow card, matches Sonarr layout).
SSO config (FleetDM-shaped, storage-only):
- New SsoStore in openpxe-core: { enabled, idp_name, metadata,
metadata_url } persisted to <work_dir>/sso.json with size caps and
URL-scheme validation.
- Endpoints: GET /api/sso, PUT /api/sso. Validation: enabling SSO
without either metadata or metadata_url returns 400.
- WebUI: SSO card in Settings with a URL-vs-XML mode switch and an
inert "Sign in with X" button on the login screen while runtime
flow is pending. Per the brief: no Entity ID field (defaults to the
advertised public_base_url internally when SAML wiring lands).
Quality:
- 132 tests passing (was 106 in v0.4.4): +5 auth unit tests, +5 SSO
unit tests, +7 auth integration tests, +1 SSO integration test, +1
regression guard pinning the new Casper cmdline.
- cargo clippy --workspace --all-targets clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1795 lines
72 KiB
Rust
1795 lines
72 KiB
Rust
//! 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::ipxe_script::{
|
|
render_entry, render_family_menu, render_local_hdd, render_menu, render_nic_info,
|
|
render_queue_entry, render_shell, render_tools_menu, render_util,
|
|
};
|
|
use crate::iso_fs;
|
|
use crate::log_stream;
|
|
use crate::state::AppState;
|
|
use crate::terminal;
|
|
use axum::{
|
|
body::{Body, Bytes},
|
|
extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path as AxumPath, Query, State},
|
|
http::{header, HeaderMap, HeaderValue, StatusCode},
|
|
response::{IntoResponse, Response},
|
|
routing::{delete, get, post, put},
|
|
Json, Router,
|
|
};
|
|
use openpxe_core::{
|
|
ext_for_mime, BootEvent, ClientEvent, Error, Settings, SsoConfig, ALLOWED_LOGO_MIMES,
|
|
MAX_LOGO_BYTES,
|
|
};
|
|
use openpxe_ipxe_assets::asset_bytes;
|
|
use openpxe_iso_store::{IsoCategory, IsoMeta, NfsAddRequest};
|
|
use serde::Deserialize;
|
|
use serde_json::json;
|
|
use std::net::SocketAddr;
|
|
use std::time::Duration;
|
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
|
use tower_http::trace::TraceLayer;
|
|
|
|
pub fn build_router(state: AppState) -> Router {
|
|
Router::new()
|
|
// Web UI (fully offline — no CDN, no external fonts/images).
|
|
.route("/", get(index))
|
|
.route("/assets/app.js", get(ui_js))
|
|
.route("/assets/app.css", get(ui_css))
|
|
.route("/assets/logo.svg", get(ui_logo))
|
|
.route("/assets/loader.svg", get(ui_loader))
|
|
// iPXE script endpoints.
|
|
.route("/boot.ipxe", get(boot_top_menu))
|
|
.route("/boot/:filename", get(boot_sub))
|
|
// Bundled binaries and raw ISO access.
|
|
.route("/ipxe/:name", get(ipxe_binary))
|
|
.route("/iso/:filename", get(iso_raw))
|
|
.route("/iso/:id/*path", get(iso_file))
|
|
// Container health/readiness probes. `/healthz` is always 200 OK
|
|
// while the HTTP task is alive. `/readyz` additionally requires at
|
|
// least one bundled iPXE binary (without one, no client can PXE).
|
|
.route("/healthz", get(healthz))
|
|
.route("/readyz", get(readyz))
|
|
// JSON API.
|
|
.route("/api/isos", get(api_list_isos).post(api_upload_iso))
|
|
.route("/api/isos/:id", delete(api_delete_iso))
|
|
.route("/api/uploads", post(api_upload_begin))
|
|
.route(
|
|
"/api/uploads/:upload_id",
|
|
put(api_upload_chunk).delete(api_upload_abort),
|
|
)
|
|
// Per-ISO password prompt. PUT body `{ "password": "..." }`
|
|
// sets, `{ "password": null }` (or DELETE) clears.
|
|
.route(
|
|
"/api/isos/:id/password",
|
|
axum::routing::put(api_set_iso_password).delete(api_clear_iso_password),
|
|
)
|
|
// v0.4.4: per-ISO menu category (Os / Tools). Drives whether the
|
|
// image appears under Linux/Windows Installers (default) or in
|
|
// the Tools submenu next to memtest / shell / NIC info.
|
|
.route(
|
|
"/api/isos/:id/category",
|
|
axum::routing::put(api_set_iso_category),
|
|
)
|
|
// v0.4.4: filesystem free-space telemetry for the ISO directory's
|
|
// volume — surfaced as a small card on the Storage tab so the
|
|
// operator knows when they're about to run out of room.
|
|
.route("/api/storage/disk", get(api_storage_disk))
|
|
// v0.4.4: operator-controlled WebUI branding overrides
|
|
// (custom logo). Multipart upload to POST; DELETE clears.
|
|
.route(
|
|
"/api/branding/logo",
|
|
post(api_branding_upload).delete(api_branding_clear),
|
|
)
|
|
// v0.4.4: self-rendered API reference, served as JSON so the UI
|
|
// can format it consistently with the rest of the chrome. Lives
|
|
// under the Settings tab — operators chasing an integration get
|
|
// it in-product instead of having to fetch the OpenAPI YAML.
|
|
.route("/api/docs", get(api_docs))
|
|
// v0.4.5: Sonarr/Radarr-style admin Forms auth. First-run
|
|
// /setup creates the single admin account; /login validates;
|
|
// /logout revokes the session; /me powers the front-end's
|
|
// "should I show the setup page, the login page, or the
|
|
// dashboard?" decision. /me/credentials rotates the admin's
|
|
// username/password.
|
|
.route("/api/setup", post(auth_api::api_setup))
|
|
.route("/api/login", post(auth_api::api_login))
|
|
.route("/api/logout", post(auth_api::api_logout))
|
|
.route("/api/me", get(auth_api::api_me))
|
|
.route(
|
|
"/api/me/credentials",
|
|
put(auth_api::api_update_credentials),
|
|
)
|
|
// v0.4.5: SAML SSO configuration (FleetDM-shaped, storage-only).
|
|
// The actual sign-in flow lands in a later release; this just
|
|
// gives operators a place to paste their IdP metadata today.
|
|
.route("/api/sso", get(api_sso_get).put(api_sso_put))
|
|
.route("/api/clients", get(api_list_clients))
|
|
.route("/api/status", get(api_status))
|
|
.route("/api/settings", get(api_get_settings).put(api_put_settings))
|
|
.route("/api/queue", get(api_list_queue))
|
|
.route("/api/queue/join", get(api_queue_join))
|
|
.route("/api/queue/poll/:entry_id", get(api_queue_poll))
|
|
.route("/api/queue/assign", post(api_queue_assign))
|
|
.route("/api/queue/:entry_id", delete(api_queue_release))
|
|
// Phase 4: NFS share manager.
|
|
.route("/api/nfs", get(api_nfs_list).post(api_nfs_add))
|
|
.route("/api/nfs/:id", delete(api_nfs_remove))
|
|
.route("/api/nfs/:id/scan", post(api_nfs_scan))
|
|
// Phase 4: Network info (read-only) + DNS edit.
|
|
.route("/api/network", get(api_network).put(api_network_put))
|
|
// Phase 4: live-log stream + recent buffer for the Terminal tab.
|
|
.route("/api/log/stream", get(log_stream::stream))
|
|
.route("/api/log/recent", get(log_stream::recent))
|
|
.route("/api/log/clear", post(log_stream::clear))
|
|
// Phase 4: operator terminal commands (whitelisted).
|
|
.route("/api/terminal", post(terminal::run_command))
|
|
// Phase 5: per-MAC host bindings. Operator
|
|
// pins a MAC to a boot entry; /boot.ipxe?mac=... chains directly.
|
|
.route("/api/hosts", get(api_hosts_list).post(api_hosts_upsert))
|
|
.route("/api/hosts/:mac", delete(api_hosts_remove))
|
|
// Rolling "host log" of boot events: what image actually
|
|
// started installing on what MAC/IP, and when. Persisted to disk.
|
|
.route("/api/boot-log", get(api_boot_log))
|
|
// Phase 5: Prometheus scrape endpoint. Plain text exposition
|
|
// format. No auth — the metrics surface is intentionally
|
|
// boring (counts, no payloads).
|
|
.route("/metrics", get(api_metrics))
|
|
// v0.4.5: Forms-auth middleware. Layered *after* `.route(...)`
|
|
// calls so it applies uniformly; passes everything through when
|
|
// no admin is configured (tests + fresh installs ride this path).
|
|
// The allowlist inside `auth_api::require_auth` keeps PXE-essential
|
|
// endpoints reachable for iPXE clients that can't authenticate.
|
|
.layer(axum::middleware::from_fn_with_state(
|
|
state.clone(),
|
|
auth_api::require_auth,
|
|
))
|
|
.layer(TraceLayer::new_for_http())
|
|
// 16 GiB upload cap — ISOs are big; chunks stream so this isn't memory use.
|
|
.layer(DefaultBodyLimit::max(16 * 1024 * 1024 * 1024))
|
|
.with_state(state)
|
|
}
|
|
|
|
// ─── SSO config endpoints ─────────────────────────────────────────────────
|
|
|
|
async fn api_sso_get(State(state): State<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>) -> Response {
|
|
match state.sso.replace(body) {
|
|
Ok(cfg) => (StatusCode::OK, Json(cfg)).into_response(),
|
|
Err(Error::Invalid(msg)) => {
|
|
(StatusCode::BAD_REQUEST, msg).into_response()
|
|
}
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
|
}
|
|
}
|
|
|
|
// ─── UI ────────────────────────────────────────────────────────────────────
|
|
|
|
async fn index(State(state): State<AppState>) -> Response {
|
|
let html = openpxe_webui::index_html(&state.public_base_url);
|
|
(
|
|
[(
|
|
header::CONTENT_TYPE,
|
|
HeaderValue::from_static("text/html; charset=utf-8"),
|
|
)],
|
|
html,
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
async fn ui_js() -> Response {
|
|
(
|
|
[(
|
|
header::CONTENT_TYPE,
|
|
HeaderValue::from_static("application/javascript"),
|
|
)],
|
|
openpxe_webui::app_js(),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
async fn ui_css() -> Response {
|
|
(
|
|
[(header::CONTENT_TYPE, HeaderValue::from_static("text/css"))],
|
|
openpxe_webui::app_css(),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
async fn ui_logo(State(state): State<AppState>) -> Response {
|
|
// Custom override first; fall back to the bundled rainbow-horizon
|
|
// SVG. We resolve the override on each request rather than caching
|
|
// because operators may upload/clear from the Settings tab while the
|
|
// server is live, and we want them to see their change immediately
|
|
// without bouncing the binary.
|
|
if let Some(path) = state.branding.logo_path() {
|
|
let mime = state
|
|
.branding
|
|
.logo_mime()
|
|
.unwrap_or_else(|| "image/svg+xml".to_string());
|
|
match tokio::fs::read(&path).await {
|
|
Ok(bytes) => {
|
|
let ct = match HeaderValue::from_str(&mime) {
|
|
Ok(v) => v,
|
|
Err(_) => HeaderValue::from_static("application/octet-stream"),
|
|
};
|
|
return (
|
|
[
|
|
(header::CONTENT_TYPE, ct),
|
|
(
|
|
header::CACHE_CONTROL,
|
|
HeaderValue::from_static("no-cache, max-age=0"),
|
|
),
|
|
],
|
|
bytes,
|
|
)
|
|
.into_response();
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
target: "openpxe::http::branding",
|
|
error = %e, "failed to read custom logo; falling back to bundled"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
(
|
|
[(
|
|
header::CONTENT_TYPE,
|
|
HeaderValue::from_static("image/svg+xml"),
|
|
)],
|
|
openpxe_webui::logo_svg(),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
async fn ui_loader() -> Response {
|
|
(
|
|
[(
|
|
header::CONTENT_TYPE,
|
|
HeaderValue::from_static("image/svg+xml"),
|
|
)],
|
|
openpxe_webui::loader_svg(),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
// ─── iPXE scripts ──────────────────────────────────────────────────────────
|
|
|
|
fn text_plain(body: String) -> Response {
|
|
(
|
|
[(
|
|
header::CONTENT_TYPE,
|
|
HeaderValue::from_static("text/plain; charset=utf-8"),
|
|
)],
|
|
body,
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
/// Top-level boot script. Honors per-MAC host bindings: if the
|
|
/// requesting client carries a `?mac=...` query param (iPXE's `${mac}`
|
|
/// substitution) and that MAC has a binding, we short-circuit straight
|
|
/// to the bound target instead of rendering the menu.
|
|
async fn boot_top_menu(
|
|
State(state): State<AppState>,
|
|
peer: Option<ConnectInfo<SocketAddr>>,
|
|
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.
|
|
let peer_ip = peer.map(|c| c.0.ip());
|
|
state
|
|
.metrics
|
|
.record_http(openpxe_core::HttpRoute::BootScript);
|
|
let isos = state.iso_store.list();
|
|
let settings = state.settings.snapshot();
|
|
let base = &state.public_base_url;
|
|
|
|
// Per-MAC override: if the client identified itself and we have a
|
|
// binding, chain directly. The chain target falls back to the menu
|
|
// on failure so a stale / misconfigured binding can't lock a client
|
|
// out — it just shows the menu.
|
|
if let Some(mac) = p.mac.as_deref() {
|
|
if let Some(binding) = state.hosts.lookup(mac) {
|
|
tracing::info!(
|
|
target: "openpxe::http",
|
|
mac = %binding.mac, target = %binding.target,
|
|
"host binding applied"
|
|
);
|
|
// Pre-record the host-binding event. Reserved menu shortcuts
|
|
// (`_local`, `_queue`, …) are operator-driven non-imaging
|
|
// targets — recording them would clutter the Host log with
|
|
// routine console activity, so we skip those and only record
|
|
// for real boot-entry ids.
|
|
if !binding.target.starts_with('_') {
|
|
let title = lookup_entry_title(&isos, &binding.target);
|
|
state.boot_log.record(&BootEvent {
|
|
timestamp: time::OffsetDateTime::now_utc(),
|
|
mac: Some(binding.mac.clone()),
|
|
ip: peer_ip,
|
|
target_id: binding.target.clone(),
|
|
target_title: title,
|
|
});
|
|
}
|
|
let target = binding.target;
|
|
let bound_mac = binding.mac;
|
|
// Reserved menu shortcuts are emitted as `_xxx`; per-entry
|
|
// boot scripts are at `/boot/<id>.ipxe`. Both share the same
|
|
// `/boot/<name>` route, so the URL is identical. We forward
|
|
// `?mac=` so the per-entry handler can record the boot into
|
|
// the Host log without depending on iPXE substitution at
|
|
// this stage.
|
|
return text_plain(format!(
|
|
"#!ipxe\n\
|
|
echo OpenPXE: per-MAC binding -> {target}\n\
|
|
chain {base}/boot/{target}.ipxe?mac={bound_mac} || chain {base}/boot.ipxe\n"
|
|
));
|
|
}
|
|
}
|
|
|
|
text_plain(render_menu(&isos, &settings, base))
|
|
}
|
|
|
|
/// Best-effort human title for a boot entry id — falls back to the id
|
|
/// itself if the ISO has been deleted between record-time and now.
|
|
fn lookup_entry_title(isos: &[openpxe_iso_store::IsoMeta], target_id: &str) -> String {
|
|
for iso in isos {
|
|
for e in &iso.boot_entries {
|
|
if e.id == target_id {
|
|
// ISO filename plus the entry title gives the operator
|
|
// both "which image" and "which variant" (e.g. wimboot
|
|
// vs sanboot) at a glance.
|
|
return format!("{} — {}", iso.filename, e.title);
|
|
}
|
|
}
|
|
}
|
|
target_id.to_string()
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct BootMenuParams {
|
|
/// Client MAC, supplied by iPXE via `${mac}` variable in
|
|
/// `chain ${prefix}/boot.ipxe?mac=${mac}`. Optional — if absent we
|
|
/// fall back to the menu unconditionally.
|
|
mac: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct BootSubParams {
|
|
/// iPXE-supplied password token. Sent by the prompt script as
|
|
/// `?token=${password:uristring}` so special chars survive URL
|
|
/// encoding. Absent on the first request — that's how we know the
|
|
/// client hasn't been prompted yet.
|
|
token: Option<String>,
|
|
/// Client MAC, supplied by iPXE via `${mac}` in the chain URLs we
|
|
/// render. Optional — older bookmarks may omit it; the boot log
|
|
/// just records `None` in that case rather than refusing to boot.
|
|
mac: Option<String>,
|
|
}
|
|
|
|
async fn boot_sub(
|
|
State(state): State<AppState>,
|
|
peer: Option<ConnectInfo<SocketAddr>>,
|
|
AxumPath(filename): AxumPath<String>,
|
|
Query(p): Query<BootSubParams>,
|
|
) -> Response {
|
|
let peer_ip = peer.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) => {
|
|
match state.iso_store.verify_password(&iso.id, token) {
|
|
Ok(true) => { /* fall through to render the entry */ }
|
|
Ok(false) => {
|
|
// Don't log the candidate — just the
|
|
// mac (when iPXE supplies one) and
|
|
// the entry id, so an operator can
|
|
// see brute-force attempts in the
|
|
// live log.
|
|
tracing::warn!(
|
|
target: "openpxe::http::boot",
|
|
entry = %other,
|
|
"wrong password supplied for protected boot entry"
|
|
);
|
|
return text_plain(
|
|
crate::ipxe_script::render_password_failed(
|
|
&entry.id, base,
|
|
),
|
|
);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(
|
|
target: "openpxe::http::boot",
|
|
entry = %other, error = %e,
|
|
"password verify failed unexpectedly"
|
|
);
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"password check failed",
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Record the boot event. This is the canonical
|
|
// moment: password gate (if any) passed, and the
|
|
// script is about to be served — i.e. the client
|
|
// is genuinely about to start imaging.
|
|
let mac_normalized = p
|
|
.mac
|
|
.as_deref()
|
|
.map(openpxe_core::normalize_mac)
|
|
.filter(|m| !m.is_empty());
|
|
state.boot_log.record(&BootEvent {
|
|
timestamp: time::OffsetDateTime::now_utc(),
|
|
mac: mac_normalized,
|
|
ip: peer_ip,
|
|
target_id: entry.id.clone(),
|
|
target_title: format!("{} — {}", iso.filename, entry.title),
|
|
});
|
|
return text_plain(render_entry(entry, &settings, base));
|
|
}
|
|
}
|
|
}
|
|
return (StatusCode::NOT_FOUND, "no such boot entry").into_response();
|
|
}
|
|
};
|
|
text_plain(script)
|
|
}
|
|
|
|
// ─── bundled iPXE binaries (memtest lives here too) ───────────────────────
|
|
|
|
async fn ipxe_binary(AxumPath(name): AxumPath<String>) -> Response {
|
|
if name.contains('/') || name.contains('\\') {
|
|
return (StatusCode::BAD_REQUEST, "invalid name").into_response();
|
|
}
|
|
let Some(bytes) = asset_bytes(&name) else {
|
|
return (StatusCode::NOT_FOUND, "no such ipxe asset").into_response();
|
|
};
|
|
(
|
|
[
|
|
(
|
|
header::CONTENT_TYPE,
|
|
HeaderValue::from_static("application/octet-stream"),
|
|
),
|
|
(header::CONTENT_LENGTH, HeaderValue::from(bytes.len())),
|
|
],
|
|
bytes,
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
// ─── ISO streaming (raw + in-ISO) ─────────────────────────────────────────
|
|
|
|
async fn iso_raw(
|
|
State(state): State<AppState>,
|
|
AxumPath(filename): AxumPath<String>,
|
|
headers: HeaderMap,
|
|
) -> Response {
|
|
let id = filename.strip_suffix(".iso").unwrap_or(&filename);
|
|
let Some(path) = state.iso_store.iso_path_for(id) else {
|
|
return (StatusCode::NOT_FOUND, "no such iso").into_response();
|
|
};
|
|
match stream_file_range(&path, headers.get(header::RANGE)).await {
|
|
Ok(r) => r,
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn iso_file(
|
|
State(state): State<AppState>,
|
|
AxumPath((id, path)): AxumPath<(String, String)>,
|
|
) -> Response {
|
|
let Some(iso_path) = state.iso_store.iso_path_for(&id) else {
|
|
return (StatusCode::NOT_FOUND, "no such iso").into_response();
|
|
};
|
|
let p = iso_path.clone();
|
|
let in_path = format!("/{path}");
|
|
let loc = tokio::task::spawn_blocking(move || iso_fs::lookup(&p, &in_path))
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
let Some(loc) = loc else {
|
|
return (StatusCode::NOT_FOUND, "not found inside iso").into_response();
|
|
};
|
|
match stream_byte_range(&iso_path, loc.offset, loc.length).await {
|
|
Ok(r) => r,
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn stream_file_range(
|
|
path: &std::path::Path,
|
|
range: Option<&HeaderValue>,
|
|
) -> anyhow::Result<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));
|
|
}
|
|
}
|
|
let mut parts = spec.splitn(2, '-');
|
|
let start = parts
|
|
.next()
|
|
.and_then(|s| s.parse::<u64>().ok())
|
|
.unwrap_or(0);
|
|
let end = parts
|
|
.next()
|
|
.and_then(|s| s.parse::<u64>().ok())
|
|
.unwrap_or(total.saturating_sub(1));
|
|
if start >= total {
|
|
return None;
|
|
}
|
|
let end = end.min(total.saturating_sub(1));
|
|
if start > end {
|
|
return None;
|
|
}
|
|
Some((start, end, true))
|
|
}
|
|
|
|
// ─── ISO upload / list / delete ───────────────────────────────────────────
|
|
|
|
async fn api_list_isos(State(state): State<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>,
|
|
mut multipart: Multipart,
|
|
) -> Response {
|
|
while let Ok(Some(field)) = multipart.next_field().await {
|
|
let name = field.name().unwrap_or("").to_string();
|
|
if name != "file" && name != "logo" {
|
|
continue;
|
|
}
|
|
let mime = field.content_type().unwrap_or("").to_string();
|
|
if !ALLOWED_LOGO_MIMES.iter().any(|m| *m == mime) {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
format!(
|
|
"unsupported MIME '{mime}'. Allowed: {}",
|
|
ALLOWED_LOGO_MIMES.join(", ")
|
|
),
|
|
)
|
|
.into_response();
|
|
}
|
|
// Pre-read into memory so we can enforce the size cap before
|
|
// hitting disk. Logos are tiny by definition.
|
|
let bytes = match field.bytes().await {
|
|
Ok(b) => b,
|
|
Err(e) => {
|
|
return (StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response()
|
|
}
|
|
};
|
|
if bytes.len() > MAX_LOGO_BYTES {
|
|
return (
|
|
StatusCode::PAYLOAD_TOO_LARGE,
|
|
format!(
|
|
"logo too large ({} bytes, max {})",
|
|
bytes.len(),
|
|
MAX_LOGO_BYTES
|
|
),
|
|
)
|
|
.into_response();
|
|
}
|
|
let Some(ext) = ext_for_mime(&mime) else {
|
|
return (StatusCode::BAD_REQUEST, "unsupported MIME").into_response();
|
|
};
|
|
match state.branding.set_logo(&mime, ext, &bytes) {
|
|
Ok(filename) => {
|
|
return (
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"filename": filename,
|
|
"mime": mime,
|
|
"size_bytes": bytes.len(),
|
|
})),
|
|
)
|
|
.into_response()
|
|
}
|
|
Err(e) => {
|
|
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response()
|
|
}
|
|
}
|
|
}
|
|
(StatusCode::BAD_REQUEST, "no 'file' part").into_response()
|
|
}
|
|
|
|
async fn api_branding_clear(State(state): State<AppState>) -> Response {
|
|
match state.branding.clear_logo() {
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
|
}
|
|
}
|
|
|
|
// ─── API reference (Settings → bottom) ────────────────────────────────────
|
|
|
|
async fn api_docs() -> Json<serde_json::Value> {
|
|
// Hand-curated rather than introspected from axum because:
|
|
// 1. axum's runtime route table doesn't carry parameter docs;
|
|
// 2. the WebUI surfaces this as a readable list, not as an OpenAPI
|
|
// spec — readers are operators chasing an integration, not
|
|
// machines.
|
|
// Keep this in lockstep with `build_router` when adding endpoints.
|
|
Json(json!({
|
|
"version": env!("CARGO_PKG_VERSION"),
|
|
"groups": [
|
|
{
|
|
"name": "Status & health",
|
|
"endpoints": [
|
|
{"method": "GET", "path": "/healthz",
|
|
"summary": "Liveness — always 200 OK while the HTTP task is alive."},
|
|
{"method": "GET", "path": "/readyz",
|
|
"summary": "Readiness — 200 only when iPXE binaries are bundled and the ISO directory is readable."},
|
|
{"method": "GET", "path": "/api/status",
|
|
"summary": "Dashboard JSON — versions, counts, settings snapshot, uptime."},
|
|
{"method": "GET", "path": "/metrics",
|
|
"summary": "Prometheus text exposition (counters + gauges)."},
|
|
],
|
|
},
|
|
{
|
|
"name": "ISO images",
|
|
"endpoints": [
|
|
{"method": "GET", "path": "/api/isos",
|
|
"summary": "List ISOs (local + NFS) with size, family, boot entries, category."},
|
|
{"method": "POST", "path": "/api/isos",
|
|
"summary": "Legacy single-shot multipart upload. Prefer /api/uploads for big files."},
|
|
{"method": "DELETE", "path": "/api/isos/:id",
|
|
"summary": "Delete a local ISO and its sidecar metadata."},
|
|
{"method": "PUT", "path": "/api/isos/:id/password",
|
|
"summary": "Set or update an ISO's boot password (bcrypt-hashed; plaintext never stored)."},
|
|
{"method": "DELETE", "path": "/api/isos/:id/password",
|
|
"summary": "Clear an ISO's boot password."},
|
|
{"method": "PUT", "path": "/api/isos/:id/category",
|
|
"summary": "Set the menu category. Body: { \"category\": \"os\" | \"tools\" }."},
|
|
],
|
|
},
|
|
{
|
|
"name": "Chunked uploads",
|
|
"endpoints": [
|
|
{"method": "POST", "path": "/api/uploads",
|
|
"summary": "Begin a chunked upload session. Body: { \"filename\", \"size_bytes\" }."},
|
|
{"method": "PUT", "path": "/api/uploads/:upload_id",
|
|
"summary": "Append a chunk. Headers: x-openpxe-upload-offset, x-openpxe-upload-complete."},
|
|
{"method": "DELETE", "path": "/api/uploads/:upload_id",
|
|
"summary": "Abort a chunked upload session and remove the .partial file."},
|
|
],
|
|
},
|
|
{
|
|
"name": "NFS shares",
|
|
"endpoints": [
|
|
{"method": "GET", "path": "/api/nfs",
|
|
"summary": "List configured NFS shares with mount state and iso counts."},
|
|
{"method": "POST", "path": "/api/nfs",
|
|
"summary": "Mount an NFS share. Body: { server, export, version, read_only }."},
|
|
{"method": "DELETE", "path": "/api/nfs/:id",
|
|
"summary": "Unmount a share and drop its entries from the ISO store."},
|
|
{"method": "POST", "path": "/api/nfs/:id/scan",
|
|
"summary": "Re-walk a mounted share for ISOs."},
|
|
],
|
|
},
|
|
{
|
|
"name": "Network",
|
|
"endpoints": [
|
|
{"method": "GET", "path": "/api/network",
|
|
"summary": "Detected NIC, server IP, subnet, gateway, advertised base URL."},
|
|
{"method": "PUT", "path": "/api/network",
|
|
"summary": "Update the informational DNS server hint (does not run DNS)."},
|
|
],
|
|
},
|
|
{
|
|
"name": "Settings",
|
|
"endpoints": [
|
|
{"method": "GET", "path": "/api/settings",
|
|
"summary": "Current runtime settings (Windows toggle, timeout, dns hint, …)."},
|
|
{"method": "PUT", "path": "/api/settings",
|
|
"summary": "Replace runtime settings. Guards against enabling Windows when wimboot isn't bundled."},
|
|
{"method": "POST", "path": "/api/branding/logo",
|
|
"summary": "Upload a custom WebUI logo (multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB)."},
|
|
{"method": "DELETE", "path": "/api/branding/logo",
|
|
"summary": "Remove the custom logo and revert to the bundled mark."},
|
|
{"method": "GET", "path": "/api/sso",
|
|
"summary": "Current SAML SSO configuration."},
|
|
{"method": "PUT", "path": "/api/sso",
|
|
"summary": "Replace SAML SSO configuration. Body: { enabled, idp_name, metadata, metadata_url }."},
|
|
{"method": "GET", "path": "/api/docs",
|
|
"summary": "This API reference."},
|
|
],
|
|
},
|
|
{
|
|
"name": "Auth (Forms)",
|
|
"endpoints": [
|
|
{"method": "POST", "path": "/api/setup",
|
|
"summary": "First-run admin bootstrap. Body: { username, password }. Refuses after the admin exists."},
|
|
{"method": "POST", "path": "/api/login",
|
|
"summary": "Sign in. Body: { username, password }. Sets the openpxe_session cookie."},
|
|
{"method": "POST", "path": "/api/logout",
|
|
"summary": "Revoke the current session and clear the cookie."},
|
|
{"method": "GET", "path": "/api/me",
|
|
"summary": "Auth status — { setup_required, authenticated, user }. Always 200."},
|
|
{"method": "PUT", "path": "/api/me/credentials",
|
|
"summary": "Rotate the admin's credentials. Body: { current_password, new_username?, new_password? }. Revokes all other sessions on success."},
|
|
],
|
|
},
|
|
{
|
|
"name": "Storage telemetry",
|
|
"endpoints": [
|
|
{"method": "GET", "path": "/api/storage/disk",
|
|
"summary": "Free / used / total bytes for the volume hosting the ISO directory."},
|
|
],
|
|
},
|
|
{
|
|
"name": "Queued Deployment",
|
|
"endpoints": [
|
|
{"method": "GET", "path": "/api/queue",
|
|
"summary": "List queue entries (waiting + assigned)."},
|
|
{"method": "POST", "path": "/api/queue/assign",
|
|
"summary": "Assign a target image to queued clients. Body: { target, entry_ids }."},
|
|
{"method": "DELETE", "path": "/api/queue/:entry_id",
|
|
"summary": "Release a queue entry without assigning."},
|
|
],
|
|
},
|
|
{
|
|
"name": "Hosts & boot log",
|
|
"endpoints": [
|
|
{"method": "GET", "path": "/api/hosts",
|
|
"summary": "List per-MAC boot bindings."},
|
|
{"method": "POST", "path": "/api/hosts",
|
|
"summary": "Pin a MAC to a boot target. Body: { mac, target, label }."},
|
|
{"method": "DELETE", "path": "/api/hosts/:mac",
|
|
"summary": "Remove a binding."},
|
|
{"method": "GET", "path": "/api/boot-log",
|
|
"summary": "Ring of recent boot events (timestamp, mac, ip, target)."},
|
|
],
|
|
},
|
|
{
|
|
"name": "Operator console",
|
|
"endpoints": [
|
|
{"method": "GET", "path": "/api/clients",
|
|
"summary": "Live PXE-client registry — MAC, last IP, arch, events."},
|
|
{"method": "GET", "path": "/api/log/recent",
|
|
"summary": "Ring of recent server log lines for the Terminal tab."},
|
|
{"method": "GET", "path": "/api/log/stream",
|
|
"summary": "Server-Sent Events stream of log lines."},
|
|
{"method": "POST", "path": "/api/terminal",
|
|
"summary": "Run a whitelisted operator command. Body: { command }."},
|
|
],
|
|
},
|
|
],
|
|
}))
|
|
}
|
|
|
|
async fn api_upload_iso(State(state): State<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() }))
|
|
}
|
|
|
|
async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
|
|
let smb = state.smb.as_ref().map(|s| s.snapshot());
|
|
let nfs = state.nfs.list();
|
|
let nfs_active = nfs.iter().filter(|m| m.mounted).count();
|
|
let isos = state.iso_store.list();
|
|
let clients = state.clients.list();
|
|
let queue_entries = state.queue.list();
|
|
// Phase 4: dashboard tracks "imaging" as queue entries with an assignment
|
|
// already issued — they're the ones actively chaining a boot script.
|
|
let imaging = queue_entries
|
|
.iter()
|
|
.filter(|entry| entry.assigned_target.is_some())
|
|
.count();
|
|
let waiting = queue_entries.len() - imaging;
|
|
// Side-effect: push gauge values out to the Prometheus surface.
|
|
// Doing it here (in the most-frequently-polled endpoint) keeps the
|
|
// gauges fresh without a dedicated scrape-time hook.
|
|
state.metrics.set_iso_count(isos.len() as u64);
|
|
state.metrics.set_client_count(clients.len() as u64);
|
|
state
|
|
.metrics
|
|
.set_queue_counts(queue_entries.len() as u64, imaging as u64);
|
|
state.metrics.set_nfs_active(nfs_active as u64);
|
|
state.metrics.record_http(openpxe_core::HttpRoute::Api);
|
|
let now = time::OffsetDateTime::now_utc();
|
|
let uptime_secs = (now - state.started_at).whole_seconds().max(0);
|
|
Json(json!({
|
|
"version": env!("CARGO_PKG_VERSION"),
|
|
"public_base_url": state.public_base_url,
|
|
"iso_count": isos.len(),
|
|
"client_count": clients.len(),
|
|
"queue_count": queue_entries.len(),
|
|
"imaging_count": imaging,
|
|
"waiting_count": waiting,
|
|
"ipxe_assets": openpxe_ipxe_assets::list_assets(),
|
|
"settings": state.settings.snapshot(),
|
|
"smb": smb,
|
|
"nfs_count": nfs.len(),
|
|
"nfs_active": nfs_active,
|
|
"host_bindings": state.hosts.len(),
|
|
"custom_logo": state.branding.has_logo(),
|
|
"uptime_secs": uptime_secs,
|
|
"started_at": state.started_at,
|
|
"nic_name": state.nic_name,
|
|
"subnet_mask": state.subnet_mask,
|
|
"gateway": state.gateway,
|
|
}))
|
|
}
|
|
|
|
async fn api_get_settings(State(state): State<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"
|
|
);
|
|
text_plain(format!(
|
|
"#!ipxe\n\
|
|
echo Queue assignment received: {target}\n\
|
|
chain {base}/boot/{target}.ipxe || chain {base}/api/queue/poll/{entry_id}\n"
|
|
))
|
|
}
|
|
Some(g) => {
|
|
// No assignment yet - loop and re-poll. Repaint position so the
|
|
// UI count stays accurate if other queue entries were released meanwhile.
|
|
text_plain(format!(
|
|
"#!ipxe\n\
|
|
echo Queue Position {} - still waiting\n\
|
|
chain {base}/api/queue/poll/{entry_id}\n",
|
|
g.position
|
|
))
|
|
}
|
|
None => text_plain(format!("#!ipxe\nchain {base}/boot.ipxe\n")),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct QueueAssignBody {
|
|
/// Boot entry id (from `BootEntry::id`). Same one used in
|
|
/// `/boot/<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_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,
|
|
}
|
|
}
|
|
|
|
// ─── NFS share API ─────────────────────────────────────────────────────────
|
|
|
|
async fn api_nfs_list(State(state): State<AppState>) -> Json<serde_json::Value> {
|
|
Json(json!({ "mounts": state.nfs.list() }))
|
|
}
|
|
|
|
async fn api_nfs_add(State(state): State<AppState>, Json(req): Json<NfsAddRequest>) -> Response {
|
|
match state.nfs.add(req).await {
|
|
Ok(m) => (StatusCode::CREATED, Json(m)).into_response(),
|
|
// Anything from the manager surfaces as a user-fixable validation
|
|
// error — bad host, kernel without NFS support, missing
|
|
// `mount.nfs`, dead server. We pass the message through verbatim
|
|
// so the UI can show it to the operator.
|
|
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn api_nfs_remove(State(state): State<AppState>, AxumPath(id): AxumPath<String>) -> Response {
|
|
match state.nfs.remove(&id).await {
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn api_nfs_scan(State(state): State<AppState>, AxumPath(id): AxumPath<String>) -> Response {
|
|
match state.nfs.rescan(&id).await {
|
|
Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(),
|
|
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
|
}
|
|
}
|
|
|
|
// ─── Network info API ──────────────────────────────────────────────────────
|
|
|
|
async fn api_network(State(state): State<AppState>) -> Json<serde_json::Value> {
|
|
Json(json!({
|
|
"server_ip": state.public_base_url
|
|
.strip_prefix("http://")
|
|
.unwrap_or(&state.public_base_url),
|
|
"nic_name": state.nic_name,
|
|
"subnet_mask": state.subnet_mask,
|
|
"gateway": state.gateway,
|
|
"dns_server": state.settings.snapshot().dns_server,
|
|
"public_base_url": state.public_base_url,
|
|
}))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct NetworkPut {
|
|
/// Operators can set or clear an informational DNS hint. Server IP /
|
|
/// NIC / mask / gateway are auto-detected and not editable from the
|
|
/// UI — changing them in the wrong direction would silently break
|
|
/// PXE for every client.
|
|
dns_server: String,
|
|
}
|
|
|
|
async fn api_network_put(
|
|
State(state): State<AppState>,
|
|
Json(body): Json<NetworkPut>,
|
|
) -> StatusCode {
|
|
let mut s = state.settings.snapshot();
|
|
s.dns_server = body.dns_server.trim().to_string();
|
|
state.settings.replace(s);
|
|
StatusCode::NO_CONTENT
|
|
}
|
|
|
|
// ─── Per-MAC host bindings ────────────────────────────────────────────────
|
|
|
|
async fn api_hosts_list(State(state): State<AppState>) -> Json<serde_json::Value> {
|
|
Json(json!({ "hosts": state.hosts.list() }))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct HostsUpsertBody {
|
|
mac: String,
|
|
target: String,
|
|
#[serde(default)]
|
|
label: String,
|
|
}
|
|
|
|
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 binding = state.hosts.upsert(mac, target, body.label.trim());
|
|
(StatusCode::CREATED, Json(binding)).into_response()
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// ─── 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);
|
|
state
|
|
.metrics
|
|
.set_nfs_active(state.nfs.list().iter().filter(|m| m.mounted).count() as u64);
|
|
|
|
let now = time::OffsetDateTime::now_utc();
|
|
let uptime = (now - state.started_at).whole_seconds().max(0) as u64;
|
|
let body = state.metrics.render(env!("CARGO_PKG_VERSION"), uptime);
|
|
(
|
|
[(
|
|
header::CONTENT_TYPE,
|
|
HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"),
|
|
)],
|
|
body,
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn range_full() {
|
|
let (s, e, p) = parse_range(None, 1000).unwrap();
|
|
assert_eq!((s, e, p), (0, 999, false));
|
|
}
|
|
#[test]
|
|
fn range_open_ended() {
|
|
let h = HeaderValue::from_static("bytes=500-");
|
|
let (s, e, p) = parse_range(Some(&h), 1000).unwrap();
|
|
assert_eq!((s, e, p), (500, 999, true));
|
|
}
|
|
#[test]
|
|
fn range_suffix() {
|
|
let h = HeaderValue::from_static("bytes=-100");
|
|
let (s, e, p) = parse_range(Some(&h), 1000).unwrap();
|
|
assert_eq!((s, e, p), (900, 999, true));
|
|
}
|
|
#[test]
|
|
fn range_explicit() {
|
|
let h = HeaderValue::from_static("bytes=10-99");
|
|
let (s, e, p) = parse_range(Some(&h), 1000).unwrap();
|
|
assert_eq!((s, e, p), (10, 99, true));
|
|
}
|
|
|
|
#[test]
|
|
fn range_rejects_out_of_bounds_start() {
|
|
let h = HeaderValue::from_static("bytes=1000-");
|
|
let got = parse_range(Some(&h), 1000);
|
|
assert_eq!(got, None);
|
|
}
|
|
|
|
#[test]
|
|
fn range_rejects_start_after_end() {
|
|
let h = HeaderValue::from_static("bytes=99-10");
|
|
let got = parse_range(Some(&h), 1000);
|
|
assert_eq!(got, None);
|
|
}
|
|
}
|