Security-driven dependency release.
- russh =0.55.0 (pinned) -> 0.61.2: closes the advisory batch reachable
from our SFTP *client* path — unbounded/allocation-first packet
parsing (CVE-2026-48110, CVE-2026-46702, CVE-2026-46673, HIGH) plus
CVE-2026-48107 in client auth. A malicious or compromised SFTP server
an operator pointed us at could previously OOM the PXE server. Also
drops mlock on non-secret buffers (~21% SSH throughput upstream) —
directly in the remote-share ISO streaming path. ring backend kept;
zero code changes needed in sftp_share.rs.
- bergshamra 0.4 -> 0.5.1: the pin's blocking condition (stable
RustCrypto generation, pkcs8 0.11) is now met upstream, so the
=0.55.0 pin is deleted and its comment rewritten as history. 0.5 is
secure-by-default for DSig (flags we already set explicitly) and
fixes an XML-Enc DerivedKey fallthrough.
- axum 0.7 -> 0.8.9: route captures /:id -> {id} across the router and
the /api/docs listing; ConnectInfo optional extraction moves to the
Result form. Gains the HEAD content-length fix (iPXE/sanboot clients
probe with HEAD before Range requests) and puts us back on the
maintained line.
Validation: clippy clean, fmt clean, all 272 workspace tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
3089 lines
127 KiB
Rust
3089 lines
127 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::error::AppError;
|
||
use crate::ipxe_script::{
|
||
render_entry, render_family_menu, render_local_hdd, render_menu, render_nic_info,
|
||
render_queue_entry, render_shell, render_tools_menu, render_util,
|
||
};
|
||
use crate::iso_fs;
|
||
use crate::log_stream;
|
||
use crate::state::AppState;
|
||
use crate::terminal;
|
||
use axum::{
|
||
body::{Body, Bytes},
|
||
extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path as AxumPath, Query, State},
|
||
http::{header, HeaderMap, HeaderValue, StatusCode},
|
||
response::{IntoResponse, Response},
|
||
routing::{delete, get, post, put},
|
||
Json, Router,
|
||
};
|
||
use openpxe_core::{
|
||
encoding::pct_encode, ext_for_mime, wol, BootEvent, ClientEvent, DeployProfile, Error,
|
||
LogoSlot, NotifyConfig, Settings, SsoConfig, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
|
||
};
|
||
use openpxe_ipxe_assets::asset_slice;
|
||
use openpxe_iso_store::{
|
||
render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SftpAddRequest, SmbAddRequest,
|
||
SmbState, UnattendedKind, UnattendedMeta,
|
||
};
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::json;
|
||
use std::net::SocketAddr;
|
||
use std::time::Duration;
|
||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||
use tower_http::trace::TraceLayer;
|
||
|
||
pub fn build_router(state: AppState) -> Router {
|
||
Router::new()
|
||
// Web UI (fully offline — no CDN, no external fonts/images).
|
||
.route("/", get(index))
|
||
.route("/assets/app.js", get(ui_js))
|
||
.route("/assets/app.css", get(ui_css))
|
||
// v0.5.2: theme-aware brand mark. `?theme=light|dark` selects the
|
||
// operator's per-theme logo slot (falling back across themes, then
|
||
// to the bundled mark). The WebUI swaps `?theme=` on theme toggle.
|
||
.route("/assets/logo.svg", get(ui_logo))
|
||
// v0.5.2: favicon is pinned to the *bundled* OpenPXE mark for
|
||
// continuity — it never follows the operator's custom branding, so
|
||
// the browser-tab icon stays recognisably "OpenPXE".
|
||
.route("/assets/favicon.svg", get(ui_favicon))
|
||
.route("/assets/loader.svg", get(ui_loader))
|
||
// v0.4.6: PXE menu logo — the raster form of the operator's
|
||
// uploaded mark, served so iPXE's `console --picture` can
|
||
// overlay it on the boot menu. SVG uploads 404 here (iPXE
|
||
// can't rasterize SVG); we deliberately don't bundle a
|
||
// pre-rendered PNG fallback because iPXE's ASCII wordmark
|
||
// banner already provides the always-visible branding.
|
||
.route("/branding/pxe-logo", get(ui_pxe_logo))
|
||
// iPXE script endpoints.
|
||
.route("/boot.ipxe", get(boot_top_menu))
|
||
.route("/boot/{filename}", get(boot_sub))
|
||
// v0.5.2: unattended answer-file *serving* — public (like /iso),
|
||
// because the booting installer fetches these with no session.
|
||
// `/unattended/:id` serves a Kickstart/Preseed with `{{HOSTNAME}}`
|
||
// / `{{IP}}` / `{{MAC}}` substituted from the query string. The
|
||
// 3-segment form is the cloud-init NoCloud seed dir for Ubuntu
|
||
// autoinstall (`…/<ctx>/user-data` + `/meta-data`), where `<ctx>`
|
||
// base64url-encodes the per-host hostname/ip/mac. Management
|
||
// (upload/list/delete) lives under the gated `/api/unattended`.
|
||
.route("/unattended/{id}", get(serve_unattended))
|
||
.route("/unattended/{id}/{ctx}/{sub}", get(serve_unattended_seed))
|
||
// Bundled binaries and raw ISO access.
|
||
.route("/ipxe/{name}", get(ipxe_binary))
|
||
.route("/iso/{filename}", get(iso_raw))
|
||
.route("/iso/{id}/{*path}", get(iso_file))
|
||
// Container health/readiness probes. `/healthz` is always 200 OK
|
||
// while the HTTP task is alive. `/readyz` additionally requires at
|
||
// least one bundled iPXE binary (without one, no client can PXE).
|
||
.route("/healthz", get(healthz))
|
||
.route("/readyz", get(readyz))
|
||
// JSON API.
|
||
.route("/api/isos", get(api_list_isos).post(api_upload_iso))
|
||
.route("/api/isos/{id}", delete(api_delete_iso))
|
||
.route("/api/uploads", post(api_upload_begin))
|
||
.route(
|
||
"/api/uploads/{upload_id}",
|
||
put(api_upload_chunk).delete(api_upload_abort),
|
||
)
|
||
// Per-ISO password prompt. PUT body `{ "password": "..." }`
|
||
// sets, `{ "password": null }` (or DELETE) clears.
|
||
.route(
|
||
"/api/isos/{id}/password",
|
||
axum::routing::put(api_set_iso_password).delete(api_clear_iso_password),
|
||
)
|
||
// v0.4.4: per-ISO menu category (Os / Tools). Drives whether the
|
||
// image appears under Linux/Windows Installers (default) or in
|
||
// the Tools submenu next to memtest / shell / NIC info.
|
||
.route(
|
||
"/api/isos/{id}/category",
|
||
axum::routing::put(api_set_iso_category),
|
||
)
|
||
// v0.4.4: filesystem free-space telemetry for the ISO directory's
|
||
// volume — surfaced as a small card on the Storage tab so the
|
||
// operator knows when they're about to run out of room.
|
||
.route("/api/storage/disk", get(api_storage_disk))
|
||
// v0.4.4: operator-controlled WebUI branding overrides (custom
|
||
// logo). v0.5.2: split into three slots — `light` / `dark` /
|
||
// `client`. Multipart upload to POST; DELETE clears one slot.
|
||
.route(
|
||
"/api/branding/logo/{slot}",
|
||
post(api_branding_upload).delete(api_branding_clear),
|
||
)
|
||
// v0.5.2: unattended-install answer-file management (gated).
|
||
// Multipart upload, list, delete. Serving is the public
|
||
// `/unattended/*` routes above.
|
||
.route(
|
||
"/api/unattended",
|
||
get(api_unattended_list).post(api_unattended_upload),
|
||
)
|
||
.route("/api/unattended/{id}", delete(api_unattended_delete))
|
||
// v0.4.4: self-rendered API reference, served as JSON so the UI
|
||
// can format it consistently with the rest of the chrome. Lives
|
||
// under the Settings tab — operators chasing an integration get
|
||
// it in-product instead of having to fetch the OpenAPI YAML.
|
||
.route("/api/docs", get(api_docs))
|
||
// v0.4.5: Sonarr/Radarr-style admin Forms auth. First-run
|
||
// /setup creates the single admin account; /login validates;
|
||
// /logout revokes the session; /me powers the front-end's
|
||
// "should I show the setup page, the login page, or the
|
||
// dashboard?" decision. /me/credentials rotates the admin's
|
||
// username/password.
|
||
.route("/api/setup", post(auth_api::api_setup))
|
||
.route("/api/login", post(auth_api::api_login))
|
||
.route("/api/logout", post(auth_api::api_logout))
|
||
.route("/api/me", get(auth_api::api_me))
|
||
.route("/api/me/credentials", put(auth_api::api_update_credentials))
|
||
// SAML SSO configuration (FleetDM-shaped). Gated behind auth — the
|
||
// operator pastes their IdP metadata, Entity ID, and toggles here.
|
||
.route("/api/sso", get(api_sso_get).put(api_sso_put))
|
||
// v0.5.1: SAML SP login flow (pre-auth — see the require_auth
|
||
// allowlist). /login redirects to the IdP, /acs consumes the signed
|
||
// response + mints a session, /metadata serves our SP descriptor.
|
||
.route("/api/sso/login", get(crate::saml_routes::sso_login))
|
||
.route("/api/sso/acs", post(crate::saml_routes::sso_acs))
|
||
.route("/api/sso/metadata", get(crate::saml_routes::sso_metadata))
|
||
.route("/api/clients", get(api_list_clients))
|
||
.route("/api/status", get(api_status))
|
||
.route("/api/settings", get(api_get_settings).put(api_put_settings))
|
||
.route("/api/queue", get(api_list_queue))
|
||
.route("/api/queue/join", get(api_queue_join))
|
||
.route("/api/queue/poll/{entry_id}", get(api_queue_poll))
|
||
.route("/api/queue/assign", post(api_queue_assign))
|
||
// v0.5.2: per-device deployment profile (auto hostname / IP /
|
||
// unattended file) set from the queue "Profile" button.
|
||
.route("/api/queue/{entry_id}/profile", put(api_queue_set_profile))
|
||
.route("/api/queue/{entry_id}", delete(api_queue_release))
|
||
// v0.4.65: SMB share manager (userspace via smbclient). The
|
||
// kernel-mount NFS routes that v0.4.64 shipped are gone — they
|
||
// didn't work on hosts whose kernel lacked the nfs client
|
||
// modules (Unraid), and no container-side configuration could
|
||
// load a host kernel module. `smbclient` speaks SMB over a
|
||
// plain TCP socket in userspace, works in every container.
|
||
.route(
|
||
"/api/smb-shares",
|
||
get(api_smb_shares_list).post(api_smb_shares_add),
|
||
)
|
||
.route("/api/smb-shares/{id}", delete(api_smb_shares_remove))
|
||
.route("/api/smb-shares/{id}/scan", post(api_smb_shares_scan))
|
||
// v0.4.67: NFSv3 share manager (pure-Rust in-process client).
|
||
// Ships alongside SMB. Routes are parallel so the UI can
|
||
// reuse the same form/error/hint rendering for both.
|
||
.route(
|
||
"/api/nfs-shares",
|
||
get(api_nfs_shares_list).post(api_nfs_shares_add),
|
||
)
|
||
.route("/api/nfs-shares/{id}", delete(api_nfs_shares_remove))
|
||
.route("/api/nfs-shares/{id}/scan", post(api_nfs_shares_scan))
|
||
// v0.5.5: SFTP-over-SSH share manager (pure-Rust russh client).
|
||
// Parallel to SMB/NFS so the UI reuses the same form/error/hint
|
||
// rendering. Like NFS, SFTP-sourced ISOs support Range requests.
|
||
.route(
|
||
"/api/sftp-shares",
|
||
get(api_sftp_shares_list).post(api_sftp_shares_add),
|
||
)
|
||
.route("/api/sftp-shares/{id}", delete(api_sftp_shares_remove))
|
||
.route("/api/sftp-shares/{id}/scan", post(api_sftp_shares_scan))
|
||
// Phase 4: Network info (read-only) + DNS edit.
|
||
.route("/api/network", get(api_network).put(api_network_put))
|
||
// Phase 4: live-log stream + recent buffer for the Terminal tab.
|
||
.route("/api/log/stream", get(log_stream::stream))
|
||
.route("/api/log/recent", get(log_stream::recent))
|
||
.route("/api/log/clear", post(log_stream::clear))
|
||
// Phase 4: operator terminal commands (whitelisted).
|
||
.route("/api/terminal", post(terminal::run_command))
|
||
// Phase 5: per-MAC host bindings. Operator
|
||
// pins a MAC to a boot entry; /boot.ipxe?mac=... chains directly.
|
||
.route("/api/hosts", get(api_hosts_list).post(api_hosts_upsert))
|
||
.route("/api/hosts/{mac}", delete(api_hosts_remove))
|
||
// v0.5.0: Wake-on-LAN a bound host. Sends a magic packet to the
|
||
// limited broadcast + the server's own subnet broadcast.
|
||
.route("/api/hosts/{mac}/wol", post(api_hosts_wol))
|
||
// Rolling "host log" of boot events: what image actually
|
||
// started installing on what MAC/IP, and when. Persisted to disk.
|
||
.route("/api/boot-log", get(api_boot_log))
|
||
// v0.5.0: notification config (Advanced tab) + a "send test"
|
||
// probe. GET redacts the SMTP password.
|
||
.route("/api/notify", get(api_notify_get).put(api_notify_put))
|
||
.route("/api/notify/test", post(api_notify_test))
|
||
// v0.5.0: About-tab update check — queries the Gitea releases
|
||
// API and compares against the running version.
|
||
.route("/api/updates/check", get(api_updates_check))
|
||
// Phase 5: Prometheus scrape endpoint. Plain text exposition
|
||
// format. No auth — the metrics surface is intentionally
|
||
// boring (counts, no payloads).
|
||
.route("/metrics", get(api_metrics))
|
||
// v0.4.5: Forms-auth middleware. Layered *after* `.route(...)`
|
||
// calls so it applies uniformly; passes everything through when
|
||
// no admin is configured (tests + fresh installs ride this path).
|
||
// The allowlist inside `auth_api::require_auth` keeps PXE-essential
|
||
// endpoints reachable for iPXE clients that can't authenticate.
|
||
.layer(axum::middleware::from_fn_with_state(
|
||
state.clone(),
|
||
auth_api::require_auth,
|
||
))
|
||
.layer(TraceLayer::new_for_http())
|
||
// 16 GiB upload cap — ISOs are big; chunks stream so this isn't memory use.
|
||
.layer(DefaultBodyLimit::max(16 * 1024 * 1024 * 1024))
|
||
.with_state(state)
|
||
}
|
||
|
||
// ─── SSO config endpoints ─────────────────────────────────────────────────
|
||
|
||
async fn api_sso_get(State(state): State<AppState>) -> Json<SsoConfig> {
|
||
// We deliberately do not redact the metadata — the operator who's
|
||
// signed in needs to be able to round-trip it. /api/sso requires
|
||
// the auth middleware anyway, so unauthenticated callers can't see
|
||
// it once admin is configured.
|
||
Json(state.sso.snapshot())
|
||
}
|
||
|
||
async fn api_sso_put(
|
||
State(state): State<AppState>,
|
||
Json(body): Json<SsoConfig>,
|
||
) -> Result<Json<SsoConfig>, AppError> {
|
||
// v0.5.4: `?` + AppError centralizes Invalid→400 / _→500.
|
||
Ok(Json(state.sso.replace(body)?))
|
||
}
|
||
|
||
// ─── UI ────────────────────────────────────────────────────────────────────
|
||
|
||
async fn index(State(state): State<AppState>) -> Response {
|
||
// The asset version pin in index.html (`?v=…`) is what makes
|
||
// browsers re-fetch JS/CSS after an upgrade. We use the OpenPXE
|
||
// binary version — every release ships a new value, every release
|
||
// forces a fresh URL on each asset.
|
||
// logo_rev cache-busts the brand mark / favicon independently of
|
||
// the release version, so an operator who swaps the custom logo
|
||
// sees it update on the next reload without waiting for an upgrade.
|
||
let html = openpxe_webui::index_html(
|
||
&state.public_base_url,
|
||
env!("CARGO_PKG_VERSION"),
|
||
state.branding.logo_rev(),
|
||
state.branding.has_any_web_logo(),
|
||
);
|
||
(
|
||
[
|
||
(
|
||
header::CONTENT_TYPE,
|
||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||
),
|
||
// index.html itself must never be cached — that's how the
|
||
// browser learns about a new `?v=…` value for the assets.
|
||
(
|
||
header::CACHE_CONTROL,
|
||
HeaderValue::from_static("no-cache, must-revalidate"),
|
||
),
|
||
],
|
||
html,
|
||
)
|
||
.into_response()
|
||
}
|
||
|
||
/// Cache-Control header value used for the bundled JS/CSS/SVG assets.
|
||
/// We pin a 1-day TTL so a long-lived deployment doesn't re-fetch the
|
||
/// same bytes on every page-load, but require revalidation — combined
|
||
/// with the `?v=<version>` query string in index.html, the practical
|
||
/// upper bound on caching across an upgrade is "until the operator
|
||
/// reloads".
|
||
const ASSET_CACHE_CONTROL: HeaderValue = HeaderValue::from_static("no-cache, must-revalidate");
|
||
|
||
async fn ui_js() -> Response {
|
||
(
|
||
[
|
||
(
|
||
header::CONTENT_TYPE,
|
||
HeaderValue::from_static("application/javascript"),
|
||
),
|
||
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
|
||
],
|
||
openpxe_webui::app_js(),
|
||
)
|
||
.into_response()
|
||
}
|
||
|
||
async fn ui_css() -> Response {
|
||
(
|
||
[
|
||
(header::CONTENT_TYPE, HeaderValue::from_static("text/css")),
|
||
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
|
||
],
|
||
openpxe_webui::app_css(),
|
||
)
|
||
.into_response()
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct LogoQuery {
|
||
/// `light` or `dark` — which theme variant the page is currently
|
||
/// showing. Anything else (or absent) resolves to the dark slot,
|
||
/// which matches the default theme.
|
||
#[serde(default)]
|
||
theme: Option<String>,
|
||
}
|
||
|
||
async fn ui_logo(State(state): State<AppState>, Query(q): Query<LogoQuery>) -> Response {
|
||
// Custom override first; fall back to the bundled rainbow-horizon
|
||
// SVG. We resolve the override on each request rather than caching
|
||
// because operators may upload/clear from the Settings tab while the
|
||
// server is live, and we want them to see their change immediately
|
||
// without bouncing the binary. The theme query selects the per-theme
|
||
// slot, with cross-theme + bundled fallback handled in BrandingStore.
|
||
let theme_is_light = q.theme.as_deref() == Some("light");
|
||
if let Some((path, mime)) = state.branding.web_logo(theme_is_light) {
|
||
match tokio::fs::read(&path).await {
|
||
Ok(bytes) => {
|
||
let ct = HeaderValue::from_str(&mime)
|
||
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"));
|
||
return (
|
||
[
|
||
(header::CONTENT_TYPE, ct),
|
||
(
|
||
header::CACHE_CONTROL,
|
||
HeaderValue::from_static("no-cache, max-age=0"),
|
||
),
|
||
],
|
||
bytes,
|
||
)
|
||
.into_response();
|
||
}
|
||
Err(e) => {
|
||
tracing::warn!(
|
||
target: "openpxe::http::branding",
|
||
error = %e, "failed to read custom logo; falling back to bundled"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
bundled_logo_response()
|
||
}
|
||
|
||
/// Favicon — always the bundled OpenPXE mark, decoupled from operator
|
||
/// branding (v0.5.2) so the browser-tab icon stays "OpenPXE" for
|
||
/// continuity regardless of any uploaded light/dark logo.
|
||
async fn ui_favicon() -> Response {
|
||
bundled_logo_response()
|
||
}
|
||
|
||
fn bundled_logo_response() -> Response {
|
||
(
|
||
[
|
||
(
|
||
header::CONTENT_TYPE,
|
||
HeaderValue::from_static("image/svg+xml"),
|
||
),
|
||
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
|
||
],
|
||
openpxe_webui::logo_svg(),
|
||
)
|
||
.into_response()
|
||
}
|
||
|
||
/// PXE boot-menu background, composed for the iPXE `console --picture`
|
||
/// call. Returns a full-screen 1024×768 PNG: a dark field with the
|
||
/// operator's uploaded logo across the top, or — when no logo is set —
|
||
/// a default OpenPXE mark on the same dark field. Either way the
|
||
/// endpoint *always* returns a valid PNG so the menu's `console
|
||
/// --picture` paints a real background instead of falling through to
|
||
/// bare text (v0.4.69 — replaces the old ASCII wordmark).
|
||
///
|
||
/// Any raster the operator uploads (PNG / JPEG / WebP / GIF) is decoded
|
||
/// and transcoded to PNG here, since iPXE only consumes PNG. SVG
|
||
/// uploads can't be rasterized without hauling in `resvg`, so an SVG
|
||
/// brand mark falls back to the *default* background for the PXE screen
|
||
/// (the WebUI still renders the SVG natively in the top-left).
|
||
async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
|
||
// The composite is a pure function of the uploaded logo, so the
|
||
// encoded PNG is cached keyed on the branding revision — an upload
|
||
// or clear bumps the rev and invalidates it. The response headers
|
||
// stay `no-cache` (clients must refetch); only the server-side
|
||
// ~50-200 ms decode/compose/encode is skipped per boot.
|
||
let rev = state.branding.logo_rev();
|
||
let cached = state
|
||
.pxe_bg_cache
|
||
.lock()
|
||
.as_ref()
|
||
.filter(|(r, _)| *r == rev)
|
||
.map(|(_, png)| png.clone());
|
||
if let Some(png) = cached {
|
||
return pxe_png_response(png);
|
||
}
|
||
|
||
// Resolve the operator's raster upload, if any and if it's a format
|
||
// iPXE/our compositor can consume. SVG (or a missing/unreadable
|
||
// file) yields `None`, which composes the default background.
|
||
let raster: Option<Vec<u8>> = match state.branding.client_logo() {
|
||
Some((path, mime)) if mime != "image/svg+xml" => tokio::fs::read(&path).await.ok(),
|
||
_ => None,
|
||
};
|
||
|
||
let composed = match tokio::task::spawn_blocking(move || {
|
||
openpxe_iso_store::pxe_logo::compose_pxe_background(raster.as_deref())
|
||
})
|
||
.await
|
||
{
|
||
Ok(Ok(png)) => png,
|
||
Ok(Err(e)) => {
|
||
// A decode failure on the operator's upload shouldn't blank
|
||
// the boot screen — fall back to the default background.
|
||
tracing::warn!(
|
||
target: "openpxe::http::branding",
|
||
error = %e, "PXE background compose failed on upload; using default"
|
||
);
|
||
match tokio::task::spawn_blocking(|| {
|
||
openpxe_iso_store::pxe_logo::compose_pxe_background(None)
|
||
})
|
||
.await
|
||
{
|
||
Ok(Ok(png)) => png,
|
||
_ => {
|
||
return (
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
"failed to compose PXE background",
|
||
)
|
||
.into_response();
|
||
}
|
||
}
|
||
}
|
||
Err(e) => {
|
||
return (
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
format!("pxe-background task failed: {e}"),
|
||
)
|
||
.into_response();
|
||
}
|
||
};
|
||
let png = bytes::Bytes::from(composed);
|
||
*state.pxe_bg_cache.lock() = Some((rev, png.clone()));
|
||
pxe_png_response(png)
|
||
}
|
||
|
||
fn pxe_png_response(png: bytes::Bytes) -> Response {
|
||
(
|
||
[
|
||
(header::CONTENT_TYPE, HeaderValue::from_static("image/png")),
|
||
// No-cache so a freshly uploaded logo paints on the next
|
||
// boot without a stale composite lingering.
|
||
(
|
||
header::CACHE_CONTROL,
|
||
HeaderValue::from_static("no-cache, max-age=0"),
|
||
),
|
||
],
|
||
png,
|
||
)
|
||
.into_response()
|
||
}
|
||
|
||
async fn ui_loader() -> Response {
|
||
(
|
||
[
|
||
(
|
||
header::CONTENT_TYPE,
|
||
HeaderValue::from_static("image/svg+xml"),
|
||
),
|
||
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
|
||
],
|
||
openpxe_webui::loader_svg(),
|
||
)
|
||
.into_response()
|
||
}
|
||
|
||
// ─── iPXE scripts ──────────────────────────────────────────────────────────
|
||
|
||
fn text_plain(body: String) -> Response {
|
||
(
|
||
[(
|
||
header::CONTENT_TYPE,
|
||
HeaderValue::from_static("text/plain; charset=utf-8"),
|
||
)],
|
||
body,
|
||
)
|
||
.into_response()
|
||
}
|
||
|
||
/// Top-level boot script. Honors per-MAC host bindings: if the
|
||
/// requesting client carries a `?mac=...` query param (iPXE's `${mac}`
|
||
/// substitution) and that MAC has a binding, we short-circuit straight
|
||
/// to the bound target instead of rendering the menu.
|
||
async fn boot_top_menu(
|
||
State(state): State<AppState>,
|
||
peer: Result<ConnectInfo<SocketAddr>, axum::extract::rejection::ExtensionRejection>,
|
||
Query(p): Query<BootMenuParams>,
|
||
) -> Response {
|
||
// `ConnectInfo` is only populated when axum was started with
|
||
// `into_make_service_with_connect_info` (production path). Tests
|
||
// call the router via `oneshot`, which skips that wiring — we
|
||
// tolerate it by treating the peer as unknown rather than 500ing.
|
||
// (axum 0.8: `Result<T, Rejection>` is the optional-extractor form.)
|
||
let peer_ip = peer.ok().map(|c| c.0.ip());
|
||
state
|
||
.metrics
|
||
.record_http(openpxe_core::HttpRoute::BootScript);
|
||
let isos = state.iso_store.list();
|
||
let settings = state.settings.snapshot();
|
||
let base = &state.public_base_url;
|
||
|
||
// Per-MAC override: if the client identified itself and we have a
|
||
// binding, chain directly. The chain target falls back to the menu
|
||
// on failure so a stale / misconfigured binding can't lock a client
|
||
// out — it just shows the menu.
|
||
if let Some(mac) = p.mac.as_deref() {
|
||
if let Some(binding) = state.hosts.lookup(mac) {
|
||
tracing::info!(
|
||
target: "openpxe::http",
|
||
mac = %binding.mac, target = %binding.target,
|
||
"host binding applied"
|
||
);
|
||
// Pre-record the host-binding event. Reserved menu shortcuts
|
||
// (`_local`, `_queue`, …) are operator-driven non-imaging
|
||
// targets — recording them would clutter the Host log with
|
||
// routine console activity, so we skip those and only record
|
||
// for real boot-entry ids.
|
||
if !binding.target.starts_with('_') {
|
||
let title = lookup_entry_title(&isos, &binding.target);
|
||
state.boot_log.record(&BootEvent {
|
||
timestamp: time::OffsetDateTime::now_utc(),
|
||
mac: Some(binding.mac.clone()),
|
||
ip: peer_ip,
|
||
target_id: binding.target.clone(),
|
||
target_title: title,
|
||
});
|
||
}
|
||
let target = binding.target;
|
||
let bound_mac = binding.mac;
|
||
// Reserved menu shortcuts are emitted as `_xxx`; per-entry
|
||
// boot scripts are at `/boot/<id>.ipxe`. Both share the same
|
||
// `/boot/<name>` route, so the URL is identical. We forward
|
||
// `?mac=` so the per-entry handler can record the boot into
|
||
// the Host log without depending on iPXE substitution at
|
||
// this stage.
|
||
return text_plain(format!(
|
||
"#!ipxe\n\
|
||
echo OpenPXE: per-MAC binding -> {target}\n\
|
||
chain {base}/boot/{target}.ipxe?mac={bound_mac} || chain {base}/boot.ipxe\n"
|
||
));
|
||
}
|
||
}
|
||
|
||
text_plain(render_menu(&isos, &settings, base))
|
||
}
|
||
|
||
/// Best-effort human title for a boot entry id — falls back to the id
|
||
/// itself if the ISO has been deleted between record-time and now.
|
||
fn lookup_entry_title(isos: &[openpxe_iso_store::IsoMeta], target_id: &str) -> String {
|
||
for iso in isos {
|
||
for e in &iso.boot_entries {
|
||
if e.id == target_id {
|
||
// ISO filename plus the entry title gives the operator
|
||
// both "which image" and "which variant" (e.g. wimboot
|
||
// vs sanboot) at a glance.
|
||
return format!("{} — {}", iso.filename, e.title);
|
||
}
|
||
}
|
||
}
|
||
target_id.to_string()
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct BootMenuParams {
|
||
/// Client MAC, supplied by iPXE via `${mac}` variable in
|
||
/// `chain ${prefix}/boot.ipxe?mac=${mac}`. Optional — if absent we
|
||
/// fall back to the menu unconditionally.
|
||
mac: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct BootSubParams {
|
||
/// iPXE-supplied password token. Sent by the prompt script as
|
||
/// `?token=${password:uristring}` so special chars survive URL
|
||
/// encoding. Absent on the first request — that's how we know the
|
||
/// client hasn't been prompted yet.
|
||
token: Option<String>,
|
||
/// Client MAC, supplied by iPXE via `${mac}` in the chain URLs we
|
||
/// render. Optional — older bookmarks may omit it; the boot log
|
||
/// just records `None` in that case rather than refusing to boot.
|
||
mac: Option<String>,
|
||
}
|
||
|
||
async fn boot_sub(
|
||
State(state): State<AppState>,
|
||
peer: Result<ConnectInfo<SocketAddr>, axum::extract::rejection::ExtensionRejection>,
|
||
AxumPath(filename): AxumPath<String>,
|
||
Query(p): Query<BootSubParams>,
|
||
) -> Response {
|
||
// axum 0.8: `Result<T, Rejection>` is the optional-extractor form.
|
||
let peer_ip = peer.ok().map(|c| c.0.ip());
|
||
// `/boot/<name>.ipxe` where `<name>` is either one of our reserved
|
||
// submenu names (prefixed `_`) or a boot entry id.
|
||
let name = filename.strip_suffix(".ipxe").unwrap_or(&filename);
|
||
let isos = state.iso_store.list();
|
||
let settings = state.settings.snapshot();
|
||
let base = &state.public_base_url;
|
||
let script = match name {
|
||
"_local" => render_local_hdd(base),
|
||
"_linux_menu" => render_family_menu(&isos, base, false),
|
||
"_windows_menu" => render_family_menu(&isos, base, true),
|
||
"_tools_menu" => render_tools_menu(&isos, base),
|
||
"_util" => render_util(base),
|
||
"_shell" => render_shell(base),
|
||
"_nic" => render_nic_info(base),
|
||
"_queue" => render_queue_entry(base),
|
||
other => {
|
||
for iso in &isos {
|
||
for entry in &iso.boot_entries {
|
||
if entry.id == other {
|
||
// Password prompt. If the ISO has a password set
|
||
// we block the actual boot script behind it:
|
||
// - no token -> render a prompt
|
||
// - wrong token -> render auth-fail
|
||
// - correct token -> serve the boot script
|
||
// ISO without a password ignores the token
|
||
// entirely, so per-MAC bookmarks stay simple.
|
||
if iso.is_password_protected() {
|
||
match p.token.as_deref() {
|
||
None | Some("") => {
|
||
return text_plain(crate::ipxe_script::render_password_prompt(
|
||
&entry.id,
|
||
&iso.filename,
|
||
base,
|
||
));
|
||
}
|
||
Some(token) => {
|
||
// bcrypt verify costs ~100-200 ms of pure
|
||
// CPU and this path is unauthenticated —
|
||
// run it on the blocking pool so password
|
||
// probes can't stall the workers that are
|
||
// streaming ISO bytes to imaging machines.
|
||
let store = state.iso_store.clone();
|
||
let iso_id = iso.id.clone();
|
||
let tok = token.to_string();
|
||
let verdict = match tokio::task::spawn_blocking(move || {
|
||
store.verify_password(&iso_id, &tok)
|
||
})
|
||
.await
|
||
{
|
||
Ok(v) => v,
|
||
Err(e) => Err(openpxe_core::Error::Other(e.into())),
|
||
};
|
||
match verdict {
|
||
Ok(true) => { /* fall through to render the entry */ }
|
||
Ok(false) => {
|
||
// Don't log the candidate — just the
|
||
// mac (when iPXE supplies one) and
|
||
// the entry id, so an operator can
|
||
// see brute-force attempts in the
|
||
// live log.
|
||
tracing::warn!(
|
||
target: "openpxe::http::boot",
|
||
entry = %other,
|
||
"wrong password supplied for protected boot entry"
|
||
);
|
||
return text_plain(
|
||
crate::ipxe_script::render_password_failed(
|
||
&entry.id, base,
|
||
),
|
||
);
|
||
}
|
||
Err(e) => {
|
||
tracing::error!(
|
||
target: "openpxe::http::boot",
|
||
entry = %other, error = %e,
|
||
"password verify failed unexpectedly"
|
||
);
|
||
return (
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
"password check failed",
|
||
)
|
||
.into_response();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Record the boot event. This is the canonical
|
||
// moment: password gate (if any) passed, and the
|
||
// script is about to be served — i.e. the client
|
||
// is genuinely about to start imaging.
|
||
let mac_normalized = p
|
||
.mac
|
||
.as_deref()
|
||
.map(openpxe_core::normalize_mac)
|
||
.filter(|m| !m.is_empty());
|
||
state.boot_log.record(&BootEvent {
|
||
timestamp: time::OffsetDateTime::now_utc(),
|
||
mac: mac_normalized.clone(),
|
||
ip: peer_ip,
|
||
target_id: entry.id.clone(),
|
||
target_title: format!("{} — {}", iso.filename, entry.title),
|
||
});
|
||
// v0.5.0: fire-and-forget notification on the
|
||
// canonical "a machine is imaging" moment.
|
||
let who = mac_normalized
|
||
.clone()
|
||
.or_else(|| peer_ip.map(|ip| ip.to_string()))
|
||
.unwrap_or_else(|| "an unknown client".into());
|
||
spawn_notify(
|
||
&state,
|
||
"PXE boot started",
|
||
&format!("{who} started booting {} ({}).", iso.filename, entry.title),
|
||
);
|
||
// v0.5.2: if this MAC has a deployment profile with
|
||
// an unattended answer file selected (via a host
|
||
// pin or queue Profile), inject the right kernel
|
||
// arg so the install runs unattended.
|
||
let unattended_args = mac_normalized.as_deref().and_then(|m| {
|
||
resolve_profile(&state, m).and_then(|p| {
|
||
p.unattended_file
|
||
.as_deref()
|
||
.and_then(|fid| state.unattended.get(fid))
|
||
.and_then(|meta| {
|
||
build_unattended_args(base, &meta, Some(m), &p)
|
||
})
|
||
})
|
||
});
|
||
return text_plain(render_entry(
|
||
entry,
|
||
&settings,
|
||
base,
|
||
unattended_args.as_deref(),
|
||
));
|
||
}
|
||
}
|
||
}
|
||
return (StatusCode::NOT_FOUND, "no such boot entry").into_response();
|
||
}
|
||
};
|
||
text_plain(script)
|
||
}
|
||
|
||
// ─── bundled iPXE binaries (memtest lives here too) ───────────────────────
|
||
|
||
async fn ipxe_binary(AxumPath(name): AxumPath<String>) -> Response {
|
||
if name.contains('/') || name.contains('\\') {
|
||
return (StatusCode::BAD_REQUEST, "invalid name").into_response();
|
||
}
|
||
let Some(data) = asset_slice(&name) else {
|
||
return (StatusCode::NOT_FOUND, "no such ipxe asset").into_response();
|
||
};
|
||
// Release builds embed the asset in rodata — serve it without the
|
||
// ~1 MiB per-request heap copy `into_owned` would cost.
|
||
let bytes = match data {
|
||
std::borrow::Cow::Borrowed(b) => bytes::Bytes::from_static(b),
|
||
std::borrow::Cow::Owned(v) => bytes::Bytes::from(v),
|
||
};
|
||
(
|
||
[
|
||
(
|
||
header::CONTENT_TYPE,
|
||
HeaderValue::from_static("application/octet-stream"),
|
||
),
|
||
(header::CONTENT_LENGTH, HeaderValue::from(bytes.len())),
|
||
],
|
||
bytes,
|
||
)
|
||
.into_response()
|
||
}
|
||
|
||
// ─── ISO streaming (raw + in-ISO) ─────────────────────────────────────────
|
||
|
||
async fn iso_raw(
|
||
State(state): State<AppState>,
|
||
AxumPath(filename): AxumPath<String>,
|
||
headers: HeaderMap,
|
||
) -> Response {
|
||
let id = filename.strip_suffix(".iso").unwrap_or(&filename);
|
||
// v0.4.65: SMB-sourced ISOs have no on-disk path — they're
|
||
// streamed live from the remote share via `smbclient`. We look
|
||
// up the meta first to decide whether to take the path-based
|
||
// local route or the subprocess-based SMB route.
|
||
let Some(meta) = state.iso_store.get(id) else {
|
||
return (StatusCode::NOT_FOUND, "no such iso").into_response();
|
||
};
|
||
match &meta.source {
|
||
IsoSource::Local => {
|
||
// `local_path(&meta)` reuses the meta we already cloned —
|
||
// `iso_path_for(id)` would re-lock and deep-clone it again,
|
||
// hundreds of times per sanboot install.
|
||
let Some(path) = state.iso_store.local_path(&meta) else {
|
||
return (StatusCode::NOT_FOUND, "no such iso").into_response();
|
||
};
|
||
match stream_file_range(&path, headers.get(header::RANGE)).await {
|
||
Ok(r) => r,
|
||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||
}
|
||
}
|
||
IsoSource::Smb {
|
||
share_id,
|
||
relative_path,
|
||
} => {
|
||
// Range requests aren't supported for SMB sources in
|
||
// v0.4.65 — smbclient's CLI can't seek mid-stream. iPXE
|
||
// chain loading and ISO sanboot do whole-file sequential
|
||
// reads, so this works in practice. A 416 here lets the
|
||
// client fall back to a full GET if it tried a range.
|
||
if headers.get(header::RANGE).is_some() {
|
||
return Response::builder()
|
||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||
.header(
|
||
header::CONTENT_RANGE,
|
||
format!("bytes */{}", meta.size_bytes),
|
||
)
|
||
.body(Body::empty())
|
||
.unwrap();
|
||
}
|
||
match state.smb_shares.stream_iso(share_id, relative_path).await {
|
||
Ok(stream) => {
|
||
let reader = stream.stdout;
|
||
let body_stream = tokio_util::io::ReaderStream::new(reader);
|
||
Response::builder()
|
||
.status(StatusCode::OK)
|
||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||
.header(header::CONTENT_LENGTH, meta.size_bytes)
|
||
// Tell intermediaries we don't support
|
||
// ranges on this resource; saves them from
|
||
// even trying.
|
||
.header(header::ACCEPT_RANGES, "none")
|
||
.body(Body::from_stream(body_stream))
|
||
.unwrap()
|
||
}
|
||
Err(e) => (StatusCode::BAD_GATEWAY, format!("smb stream: {e}")).into_response(),
|
||
}
|
||
}
|
||
IsoSource::Nfs {
|
||
share_id,
|
||
relative_path,
|
||
} => {
|
||
// v0.4.67: NFS sources support Range requests because
|
||
// NFSv3 READ3 takes an explicit offset. We resolve the
|
||
// requested byte range here and pass start/len down to
|
||
// the streamer which seeks into the file via READ3.
|
||
let total = meta.size_bytes;
|
||
let range = match parse_range(headers.get(header::RANGE), total) {
|
||
Some(triple) => triple,
|
||
None if headers.get(header::RANGE).is_some() => {
|
||
return Response::builder()
|
||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||
.header(header::CONTENT_RANGE, format!("bytes */{total}"))
|
||
.body(Body::empty())
|
||
.unwrap();
|
||
}
|
||
// No Range header — serve the whole file.
|
||
None => (0, total.saturating_sub(1), false),
|
||
};
|
||
let (start, end, partial) = range;
|
||
let len = if total == 0 { 0 } else { end - start + 1 };
|
||
let max_len = if total == 0 { None } else { Some(len) };
|
||
match state
|
||
.nfs_shares
|
||
.stream_iso(share_id, relative_path, start, max_len)
|
||
.await
|
||
{
|
||
Ok(stream) => {
|
||
let body = Body::from_stream(stream);
|
||
let status = if partial {
|
||
StatusCode::PARTIAL_CONTENT
|
||
} else {
|
||
StatusCode::OK
|
||
};
|
||
let mut builder = Response::builder()
|
||
.status(status)
|
||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||
.header(header::ACCEPT_RANGES, "bytes")
|
||
.header(header::CONTENT_LENGTH, len);
|
||
if partial {
|
||
builder = builder.header(
|
||
header::CONTENT_RANGE,
|
||
format!("bytes {start}-{end}/{total}"),
|
||
);
|
||
}
|
||
builder.body(body).unwrap()
|
||
}
|
||
Err(e) => (StatusCode::BAD_GATEWAY, format!("nfs stream: {e}")).into_response(),
|
||
}
|
||
}
|
||
IsoSource::Sftp {
|
||
share_id,
|
||
relative_path,
|
||
} => {
|
||
// v0.5.5: SFTP sources support Range requests because SFTP
|
||
// opens a seekable file handle (seek to offset, then bounded
|
||
// reads). Identical handling to the NFS arm above.
|
||
let total = meta.size_bytes;
|
||
let range = match parse_range(headers.get(header::RANGE), total) {
|
||
Some(triple) => triple,
|
||
None if headers.get(header::RANGE).is_some() => {
|
||
return Response::builder()
|
||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||
.header(header::CONTENT_RANGE, format!("bytes */{total}"))
|
||
.body(Body::empty())
|
||
.unwrap();
|
||
}
|
||
// No Range header — serve the whole file.
|
||
None => (0, total.saturating_sub(1), false),
|
||
};
|
||
let (start, end, partial) = range;
|
||
let len = if total == 0 { 0 } else { end - start + 1 };
|
||
let max_len = if total == 0 { None } else { Some(len) };
|
||
match state
|
||
.sftp_shares
|
||
.stream_iso(share_id, relative_path, start, max_len)
|
||
.await
|
||
{
|
||
Ok(stream) => {
|
||
let body = Body::from_stream(stream);
|
||
let status = if partial {
|
||
StatusCode::PARTIAL_CONTENT
|
||
} else {
|
||
StatusCode::OK
|
||
};
|
||
let mut builder = Response::builder()
|
||
.status(status)
|
||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||
.header(header::ACCEPT_RANGES, "bytes")
|
||
.header(header::CONTENT_LENGTH, len);
|
||
if partial {
|
||
builder = builder.header(
|
||
header::CONTENT_RANGE,
|
||
format!("bytes {start}-{end}/{total}"),
|
||
);
|
||
}
|
||
builder.body(body).unwrap()
|
||
}
|
||
Err(e) => (StatusCode::BAD_GATEWAY, format!("sftp stream: {e}")).into_response(),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn iso_file(
|
||
State(state): State<AppState>,
|
||
AxumPath((id, path)): AxumPath<(String, String)>,
|
||
) -> Response {
|
||
// In-ISO file extraction is only supported for local ISOs — it
|
||
// needs random-access reads into the ISO9660 directory tree, which
|
||
// smbclient's whole-file streaming can't do efficiently. SMB-
|
||
// sourced ISOs use the raw streaming endpoint above instead.
|
||
let Some(iso_path) = state.iso_store.iso_path_for(&id) else {
|
||
return (StatusCode::NOT_FOUND, "no such iso").into_response();
|
||
};
|
||
let p = iso_path.clone();
|
||
let in_path = format!("/{path}");
|
||
let loc = tokio::task::spawn_blocking(move || iso_fs::lookup(&p, &in_path))
|
||
.await
|
||
.ok()
|
||
.flatten();
|
||
let Some(loc) = loc else {
|
||
return (StatusCode::NOT_FOUND, "not found inside iso").into_response();
|
||
};
|
||
match stream_byte_range(&iso_path, loc.offset, loc.length).await {
|
||
Ok(r) => r,
|
||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||
}
|
||
}
|
||
|
||
async fn stream_file_range(
|
||
path: &std::path::Path,
|
||
range: Option<&HeaderValue>,
|
||
) -> anyhow::Result<Response> {
|
||
let meta = tokio::fs::metadata(path).await?;
|
||
let total = meta.len();
|
||
if total == 0 {
|
||
return Ok(Response::builder()
|
||
.status(StatusCode::OK)
|
||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||
.header(header::ACCEPT_RANGES, "bytes")
|
||
.header(header::CONTENT_LENGTH, 0)
|
||
.body(Body::empty())
|
||
.unwrap());
|
||
}
|
||
let Some((start, end, partial)) = parse_range(range, total) else {
|
||
return Ok(Response::builder()
|
||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||
.header(header::CONTENT_RANGE, format!("bytes */{total}"))
|
||
.body(Body::empty())
|
||
.unwrap());
|
||
};
|
||
let len = end - start + 1;
|
||
let mut file = tokio::fs::File::open(path).await?;
|
||
file.seek(std::io::SeekFrom::Start(start)).await?;
|
||
let reader = file.take(len);
|
||
let stream = tokio_util::io::ReaderStream::new(reader);
|
||
let body = Body::from_stream(stream);
|
||
let status = if partial {
|
||
StatusCode::PARTIAL_CONTENT
|
||
} else {
|
||
StatusCode::OK
|
||
};
|
||
let mut builder = Response::builder()
|
||
.status(status)
|
||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||
.header(header::ACCEPT_RANGES, "bytes")
|
||
.header(header::CONTENT_LENGTH, len);
|
||
if partial {
|
||
builder = builder.header(
|
||
header::CONTENT_RANGE,
|
||
format!("bytes {start}-{end}/{total}"),
|
||
);
|
||
}
|
||
Ok(builder.body(body).unwrap())
|
||
}
|
||
|
||
async fn stream_byte_range(
|
||
path: &std::path::Path,
|
||
offset: u64,
|
||
length: u64,
|
||
) -> anyhow::Result<Response> {
|
||
let mut file = tokio::fs::File::open(path).await?;
|
||
file.seek(std::io::SeekFrom::Start(offset)).await?;
|
||
let reader = file.take(length);
|
||
let stream = tokio_util::io::ReaderStream::new(reader);
|
||
let body = Body::from_stream(stream);
|
||
Ok(Response::builder()
|
||
.status(StatusCode::OK)
|
||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||
.header(header::CONTENT_LENGTH, length)
|
||
.body(body)
|
||
.unwrap())
|
||
}
|
||
|
||
fn parse_range(h: Option<&HeaderValue>, total: u64) -> Option<(u64, u64, bool)> {
|
||
let Some(h) = h else {
|
||
return Some((0, total.saturating_sub(1), false));
|
||
};
|
||
let Ok(s) = h.to_str() else {
|
||
return Some((0, total.saturating_sub(1), false));
|
||
};
|
||
let Some(spec) = s.strip_prefix("bytes=") else {
|
||
return Some((0, total.saturating_sub(1), false));
|
||
};
|
||
let spec = spec.split(',').next().unwrap_or("").trim();
|
||
if let Some(suffix) = spec.strip_prefix('-') {
|
||
if let Ok(n) = suffix.parse::<u64>() {
|
||
let n = n.min(total);
|
||
return Some((total.saturating_sub(n), total.saturating_sub(1), true));
|
||
}
|
||
}
|
||
// RFC 7233 §3.1: a Range header we can't parse is *ignored* (200 +
|
||
// full body), never coerced into a bogus 206 claiming the whole
|
||
// file. Only `first-pos[-last-pos]` with numeric positions reaches
|
||
// the partial path; `None` is reserved for syntactically valid but
|
||
// unsatisfiable ranges (→ 416).
|
||
let full = Some((0, total.saturating_sub(1), false));
|
||
let Some((start_s, end_s)) = spec.split_once('-') else {
|
||
return full;
|
||
};
|
||
let Ok(start) = start_s.trim().parse::<u64>() else {
|
||
return full;
|
||
};
|
||
let end = if end_s.trim().is_empty() {
|
||
total.saturating_sub(1)
|
||
} else if let Ok(e) = end_s.trim().parse::<u64>() {
|
||
e
|
||
} else {
|
||
return full;
|
||
};
|
||
if start >= total {
|
||
return None;
|
||
}
|
||
let end = end.min(total.saturating_sub(1));
|
||
if start > end {
|
||
return None;
|
||
}
|
||
Some((start, end, true))
|
||
}
|
||
|
||
// ─── ISO upload / list / delete ───────────────────────────────────────────
|
||
|
||
async fn api_list_isos(State(state): State<AppState>) -> Json<Vec<IsoMeta>> {
|
||
Json(state.iso_store.list())
|
||
}
|
||
|
||
async fn api_delete_iso(
|
||
State(state): State<AppState>,
|
||
AxumPath(id): AxumPath<String>,
|
||
) -> StatusCode {
|
||
match state.iso_store.delete(&id).await {
|
||
Ok(()) => StatusCode::NO_CONTENT,
|
||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct SetPasswordBody {
|
||
/// Plaintext password. `null` or empty/whitespace clears the
|
||
/// password (same as a DELETE on this resource). The server hashes
|
||
/// with bcrypt before persisting; the plaintext is never stored.
|
||
password: Option<String>,
|
||
}
|
||
|
||
async fn api_set_iso_password(
|
||
State(state): State<AppState>,
|
||
AxumPath(id): AxumPath<String>,
|
||
Json(body): Json<SetPasswordBody>,
|
||
) -> Response {
|
||
match state
|
||
.iso_store
|
||
.set_password(&id, body.password.as_deref())
|
||
.await
|
||
{
|
||
Ok(()) => {
|
||
let now_protected = state
|
||
.iso_store
|
||
.get(&id)
|
||
.is_some_and(|m| m.is_password_protected());
|
||
// We deliberately do not log the password value, only
|
||
// whether the ISO ended up protected.
|
||
tracing::info!(
|
||
target: "openpxe::http::iso",
|
||
iso = %id, protected = now_protected,
|
||
"iso password updated"
|
||
);
|
||
StatusCode::NO_CONTENT.into_response()
|
||
}
|
||
Err(openpxe_error_invalid)
|
||
if matches!(openpxe_error_invalid, openpxe_core::Error::Invalid(_)) =>
|
||
{
|
||
(StatusCode::NOT_FOUND, format!("{openpxe_error_invalid}")).into_response()
|
||
}
|
||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||
}
|
||
}
|
||
|
||
async fn api_clear_iso_password(
|
||
State(state): State<AppState>,
|
||
AxumPath(id): AxumPath<String>,
|
||
) -> Response {
|
||
match state.iso_store.set_password(&id, None).await {
|
||
Ok(()) => {
|
||
tracing::info!(
|
||
target: "openpxe::http::iso",
|
||
iso = %id, "iso password cleared"
|
||
);
|
||
StatusCode::NO_CONTENT.into_response()
|
||
}
|
||
Err(e) => (StatusCode::NOT_FOUND, format!("{e}")).into_response(),
|
||
}
|
||
}
|
||
|
||
// ─── ISO category (OS / Tools) ────────────────────────────────────────────
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct SetCategoryBody {
|
||
/// `"os"` or `"tools"` — matches `IsoCategory`'s snake_case serde
|
||
/// repr. Anything else returns 400 with the allowed set spelled out.
|
||
category: String,
|
||
}
|
||
|
||
async fn api_set_iso_category(
|
||
State(state): State<AppState>,
|
||
AxumPath(id): AxumPath<String>,
|
||
Json(body): Json<SetCategoryBody>,
|
||
) -> Response {
|
||
let cat = match body.category.as_str() {
|
||
"os" => IsoCategory::Os,
|
||
"tools" => IsoCategory::Tools,
|
||
other => {
|
||
return (
|
||
StatusCode::BAD_REQUEST,
|
||
format!("unknown category '{other}'; expected one of: os, tools"),
|
||
)
|
||
.into_response();
|
||
}
|
||
};
|
||
match state.iso_store.set_category(&id, cat).await {
|
||
Ok(meta) => {
|
||
tracing::info!(
|
||
target: "openpxe::http::iso",
|
||
iso = %id, category = ?cat,
|
||
"iso category updated"
|
||
);
|
||
(StatusCode::OK, Json(meta)).into_response()
|
||
}
|
||
Err(Error::Invalid(msg)) => (StatusCode::NOT_FOUND, msg).into_response(),
|
||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||
}
|
||
}
|
||
|
||
// ─── Disk space (Storage tab) ─────────────────────────────────────────────
|
||
|
||
async fn api_storage_disk(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||
// statvfs on the directory that holds the ISO store. We deliberately
|
||
// don't walk the directory ourselves — the kernel already tracks
|
||
// free/total at the volume level and that's the only number the
|
||
// operator actually cares about for "do I have room for one more
|
||
// 5 GB ISO?". `statvfs` itself lives in iso-store to keep the
|
||
// http-api crate free of `unsafe`.
|
||
let dir = state.iso_store.iso_dir();
|
||
let (total, available) = state.iso_store.disk_usage().unwrap_or((0, 0));
|
||
let used = total.saturating_sub(available);
|
||
Json(json!({
|
||
"path": dir.to_string_lossy(),
|
||
"total_bytes": total,
|
||
"available_bytes": available,
|
||
"used_bytes": used,
|
||
}))
|
||
}
|
||
|
||
// ─── Branding (custom logo) ───────────────────────────────────────────────
|
||
|
||
async fn api_branding_upload(
|
||
State(state): State<AppState>,
|
||
AxumPath(slot): AxumPath<String>,
|
||
mut multipart: Multipart,
|
||
) -> Response {
|
||
let Some(slot) = LogoSlot::parse(&slot) else {
|
||
return (
|
||
StatusCode::BAD_REQUEST,
|
||
"unknown logo slot; expected light, dark, or client",
|
||
)
|
||
.into_response();
|
||
};
|
||
while let Ok(Some(field)) = multipart.next_field().await {
|
||
let name = field.name().unwrap_or("").to_string();
|
||
if name != "file" && name != "logo" {
|
||
continue;
|
||
}
|
||
let mime = field.content_type().unwrap_or("").to_string();
|
||
if !ALLOWED_LOGO_MIMES.iter().any(|m| *m == mime) {
|
||
return (
|
||
StatusCode::BAD_REQUEST,
|
||
format!(
|
||
"unsupported MIME '{mime}'. Allowed: {}",
|
||
ALLOWED_LOGO_MIMES.join(", ")
|
||
),
|
||
)
|
||
.into_response();
|
||
}
|
||
// Pre-read into memory so we can enforce the size cap before
|
||
// hitting disk. Logos are tiny by definition.
|
||
let bytes = match field.bytes().await {
|
||
Ok(b) => b,
|
||
Err(e) => return (StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response(),
|
||
};
|
||
if bytes.len() > MAX_LOGO_BYTES {
|
||
return (
|
||
StatusCode::PAYLOAD_TOO_LARGE,
|
||
format!(
|
||
"logo too large ({} bytes, max {})",
|
||
bytes.len(),
|
||
MAX_LOGO_BYTES
|
||
),
|
||
)
|
||
.into_response();
|
||
}
|
||
let Some(ext) = ext_for_mime(&mime) else {
|
||
return (StatusCode::BAD_REQUEST, "unsupported MIME").into_response();
|
||
};
|
||
// The PXE "client" logo is rasterized for the boot screen; an SVG
|
||
// there can't be composited, so steer operators to a raster.
|
||
if slot == LogoSlot::Client && mime == "image/svg+xml" {
|
||
return (
|
||
StatusCode::BAD_REQUEST,
|
||
"the client (PXE) logo must be a raster image (PNG/JPEG/WebP/GIF); SVG can't be painted on the boot screen",
|
||
)
|
||
.into_response();
|
||
}
|
||
match state.branding.set_logo(slot, &mime, ext, &bytes) {
|
||
Ok(filename) => {
|
||
return (
|
||
StatusCode::OK,
|
||
Json(json!({
|
||
"slot": slot.as_str(),
|
||
"filename": filename,
|
||
"mime": mime,
|
||
"size_bytes": bytes.len(),
|
||
})),
|
||
)
|
||
.into_response()
|
||
}
|
||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||
}
|
||
}
|
||
(StatusCode::BAD_REQUEST, "no 'file' part").into_response()
|
||
}
|
||
|
||
async fn api_branding_clear(
|
||
State(state): State<AppState>,
|
||
AxumPath(slot): AxumPath<String>,
|
||
) -> Result<Response, AppError> {
|
||
let Some(slot) = LogoSlot::parse(&slot) else {
|
||
return Ok((StatusCode::BAD_REQUEST, "unknown logo slot").into_response());
|
||
};
|
||
state.branding.clear_logo(slot)?;
|
||
Ok(StatusCode::NO_CONTENT.into_response())
|
||
}
|
||
|
||
// ─── Unattended answer files (v0.5.2) ──────────────────────────────────────
|
||
//
|
||
// Management (list/upload/delete) is gated behind the auth middleware.
|
||
// *Serving* the files to the booting installer is the public
|
||
// `/unattended/*` route pair below — the installer has no session.
|
||
|
||
async fn api_unattended_list(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||
Json(json!({ "files": state.unattended.list() }))
|
||
}
|
||
|
||
async fn api_unattended_upload(
|
||
State(state): State<AppState>,
|
||
mut multipart: Multipart,
|
||
) -> Result<Response, AppError> {
|
||
// v0.5.4: the answer-file add() maps Invalid→400 / _→500 via `?`+AppError.
|
||
// The multipart-shape 400s (missing field/filename) stay explicit.
|
||
while let Ok(Some(field)) = multipart.next_field().await {
|
||
let name = field.name().unwrap_or("").to_string();
|
||
if name != "file" && name != "unattended" {
|
||
continue;
|
||
}
|
||
let filename = field.file_name().map(str::to_string).unwrap_or_default();
|
||
if filename.trim().is_empty() {
|
||
return Ok((StatusCode::BAD_REQUEST, "missing filename on upload").into_response());
|
||
}
|
||
let bytes = match field.bytes().await {
|
||
Ok(b) => b,
|
||
Err(e) => {
|
||
return Ok((StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response())
|
||
}
|
||
};
|
||
let meta = state.unattended.add(&filename, &bytes).await?;
|
||
return Ok((StatusCode::CREATED, Json(meta)).into_response());
|
||
}
|
||
Ok((StatusCode::BAD_REQUEST, "no 'file' part").into_response())
|
||
}
|
||
|
||
async fn api_unattended_delete(
|
||
State(state): State<AppState>,
|
||
AxumPath(id): AxumPath<String>,
|
||
) -> StatusCode {
|
||
if state.unattended.remove(&id).await {
|
||
StatusCode::NO_CONTENT
|
||
} else {
|
||
StatusCode::NOT_FOUND
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct UnattendedServeQuery {
|
||
#[serde(default)]
|
||
mac: Option<String>,
|
||
#[serde(default)]
|
||
hostname: Option<String>,
|
||
#[serde(default)]
|
||
ip: Option<String>,
|
||
}
|
||
|
||
/// Public: serve a Kickstart/Preseed/answer file with `{{HOSTNAME}}` /
|
||
/// `{{IP}}` / `{{MAC}}` substituted from the query string. Returns
|
||
/// `text/plain` so installers (anaconda, debian-installer, Windows setup
|
||
/// fetching over HTTP) read it verbatim.
|
||
async fn serve_unattended(
|
||
State(state): State<AppState>,
|
||
AxumPath(id): AxumPath<String>,
|
||
Query(q): Query<UnattendedServeQuery>,
|
||
) -> Response {
|
||
let Ok(bytes) = state.unattended.read(&id).await else {
|
||
return (StatusCode::NOT_FOUND, "no such unattended file").into_response();
|
||
};
|
||
let content = String::from_utf8_lossy(&bytes);
|
||
let rendered = render_template(
|
||
&content,
|
||
q.mac.as_deref(),
|
||
q.hostname.as_deref(),
|
||
q.ip.as_deref(),
|
||
);
|
||
text_plain(rendered)
|
||
}
|
||
|
||
/// Public: cloud-init NoCloud seed directory for Ubuntu autoinstall. The
|
||
/// kernel arg points iPXE/cloud-init at `…/<id>/<ctx>/`; cloud-init then
|
||
/// fetches `user-data`, `meta-data`, (and `vendor-data`). `<ctx>`
|
||
/// base64url-encodes the per-host hostname/ip/mac so they survive the
|
||
/// seedfrom URL (which can't carry a query string).
|
||
async fn serve_unattended_seed(
|
||
State(state): State<AppState>,
|
||
AxumPath((id, ctx, sub)): AxumPath<(String, String, String)>,
|
||
) -> Response {
|
||
let (mac, hostname, ip) = decode_seed_ctx(&ctx);
|
||
match sub.as_str() {
|
||
"user-data" => {
|
||
let Ok(bytes) = state.unattended.read(&id).await else {
|
||
return (StatusCode::NOT_FOUND, "no such unattended file").into_response();
|
||
};
|
||
let content = String::from_utf8_lossy(&bytes);
|
||
let rendered =
|
||
render_template(&content, mac.as_deref(), hostname.as_deref(), ip.as_deref());
|
||
text_plain(rendered)
|
||
}
|
||
"meta-data" => {
|
||
let host_line = hostname
|
||
.as_deref()
|
||
.map(|h| format!("local-hostname: {h}\n"))
|
||
.unwrap_or_default();
|
||
text_plain(format!("instance-id: openpxe-{id}\n{host_line}"))
|
||
}
|
||
// cloud-init probes vendor-data too; an empty 200 keeps it quiet.
|
||
"vendor-data" => text_plain(String::new()),
|
||
_ => (StatusCode::NOT_FOUND, "unknown seed resource").into_response(),
|
||
}
|
||
}
|
||
|
||
/// Resolve the deployment profile for a booting MAC: a host pin wins, else
|
||
/// a queued device's Profile. `None` when neither carries one.
|
||
fn resolve_profile(state: &AppState, mac: &str) -> Option<DeployProfile> {
|
||
if let Some(b) = state.hosts.lookup(mac) {
|
||
if !b.profile.is_empty() {
|
||
return Some(b.profile);
|
||
}
|
||
}
|
||
state.queue.profile_for_mac(mac)
|
||
}
|
||
|
||
/// Build the per-host unattended kernel arguments for a Linux entry.
|
||
/// Returns `None` for Windows answer files / unclassified uploads (no
|
||
/// kernel cmdline injection applies).
|
||
fn build_unattended_args(
|
||
base: &str,
|
||
meta: &UnattendedMeta,
|
||
mac: Option<&str>,
|
||
profile: &DeployProfile,
|
||
) -> Option<String> {
|
||
let base = base.trim_end_matches('/');
|
||
let id = &meta.id;
|
||
let host = profile.auto_hostname.as_deref();
|
||
let ip = profile.auto_ip.as_deref();
|
||
let query = build_query(&[("mac", mac), ("hostname", host), ("ip", ip)]);
|
||
match meta.kind {
|
||
UnattendedKind::Kickstart => Some(format!("inst.ks={base}/unattended/{id}{query}")),
|
||
UnattendedKind::Preseed => {
|
||
let mut s = format!("auto=true priority=critical url={base}/unattended/{id}{query}");
|
||
if let Some(h) = host {
|
||
s.push_str(" hostname=");
|
||
s.push_str(h);
|
||
}
|
||
Some(s)
|
||
}
|
||
UnattendedKind::Autoinstall => {
|
||
let ctx = encode_seed_ctx(mac, host, ip);
|
||
Some(format!(
|
||
"autoinstall ds=nocloud-net;s={base}/unattended/{id}/{ctx}/"
|
||
))
|
||
}
|
||
UnattendedKind::AnswerFile | UnattendedKind::Unknown => None,
|
||
}
|
||
}
|
||
|
||
/// Build a `?k=v&…` query string from present key/value pairs, percent-
|
||
/// encoding the values. Empty when nothing is present.
|
||
fn build_query(pairs: &[(&str, Option<&str>)]) -> String {
|
||
use std::fmt::Write as _;
|
||
let mut out = String::new();
|
||
for (k, v) in pairs {
|
||
if let Some(val) = v {
|
||
out.push(if out.is_empty() { '?' } else { '&' });
|
||
let _ = write!(out, "{k}={}", pct_encode(val));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
// `pct_encode` lives in `openpxe_core::encoding` (v0.5.4) — imported above.
|
||
|
||
/// Encode `(hostname, ip, mac)` into a single base64url path segment for
|
||
/// the cloud-init seed directory. Empty values become empty fields.
|
||
fn encode_seed_ctx(mac: Option<&str>, hostname: Option<&str>, ip: Option<&str>) -> String {
|
||
use base64::Engine as _;
|
||
let raw = format!(
|
||
"{}\n{}\n{}",
|
||
hostname.unwrap_or(""),
|
||
ip.unwrap_or(""),
|
||
mac.unwrap_or("")
|
||
);
|
||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes())
|
||
}
|
||
|
||
/// Inverse of [`encode_seed_ctx`]; returns `(mac, hostname, ip)`. A bad
|
||
/// or empty segment yields all-`None` so the seed still serves (just
|
||
/// without per-host substitution).
|
||
fn decode_seed_ctx(ctx: &str) -> (Option<String>, Option<String>, Option<String>) {
|
||
use base64::Engine as _;
|
||
let Ok(bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(ctx.as_bytes()) else {
|
||
return (None, None, None);
|
||
};
|
||
let s = String::from_utf8_lossy(&bytes).into_owned();
|
||
let mut it = s.splitn(3, '\n');
|
||
let clean = |v: Option<&str>| v.map(str::to_string).filter(|x| !x.is_empty());
|
||
let hostname = clean(it.next());
|
||
let ip = clean(it.next());
|
||
let mac = clean(it.next());
|
||
(mac, hostname, ip)
|
||
}
|
||
|
||
// ─── API reference (Settings → bottom) ────────────────────────────────────
|
||
|
||
async fn api_docs() -> Json<serde_json::Value> {
|
||
// Hand-curated rather than introspected from axum because:
|
||
// 1. axum's runtime route table doesn't carry parameter docs;
|
||
// 2. the WebUI surfaces this as a readable list, not as an OpenAPI
|
||
// spec — readers are operators chasing an integration, not
|
||
// machines.
|
||
// Keep this in lockstep with `build_router` when adding endpoints.
|
||
Json(json!({
|
||
"version": env!("CARGO_PKG_VERSION"),
|
||
"groups": [
|
||
{
|
||
"name": "Status & health",
|
||
"endpoints": [
|
||
{"method": "GET", "path": "/healthz",
|
||
"summary": "Liveness — always 200 OK while the HTTP task is alive."},
|
||
{"method": "GET", "path": "/readyz",
|
||
"summary": "Readiness — 200 only when iPXE binaries are bundled and the ISO directory is readable."},
|
||
{"method": "GET", "path": "/api/status",
|
||
"summary": "Dashboard JSON — versions, counts, settings snapshot, uptime."},
|
||
{"method": "GET", "path": "/metrics",
|
||
"summary": "Prometheus text exposition (counters + gauges)."},
|
||
],
|
||
},
|
||
{
|
||
"name": "ISO images",
|
||
"endpoints": [
|
||
{"method": "GET", "path": "/api/isos",
|
||
"summary": "List ISOs (local + NFS) with size, family, boot entries, category."},
|
||
{"method": "POST", "path": "/api/isos",
|
||
"summary": "Legacy single-shot multipart upload. Prefer /api/uploads for big files."},
|
||
{"method": "DELETE", "path": "/api/isos/{id}",
|
||
"summary": "Delete a local ISO and its sidecar metadata."},
|
||
{"method": "PUT", "path": "/api/isos/{id}/password",
|
||
"summary": "Set or update an ISO's boot password (bcrypt-hashed; plaintext never stored)."},
|
||
{"method": "DELETE", "path": "/api/isos/{id}/password",
|
||
"summary": "Clear an ISO's boot password."},
|
||
{"method": "PUT", "path": "/api/isos/{id}/category",
|
||
"summary": "Set the menu category. Body: { \"category\": \"os\" | \"tools\" }."},
|
||
],
|
||
},
|
||
{
|
||
"name": "Chunked uploads",
|
||
"endpoints": [
|
||
{"method": "POST", "path": "/api/uploads",
|
||
"summary": "Begin a chunked upload session. Body: { \"filename\", \"size_bytes\" }."},
|
||
{"method": "PUT", "path": "/api/uploads/{upload_id}",
|
||
"summary": "Append a chunk. Headers: x-openpxe-upload-offset, x-openpxe-upload-complete."},
|
||
{"method": "DELETE", "path": "/api/uploads/{upload_id}",
|
||
"summary": "Abort a chunked upload session and remove the .partial file."},
|
||
],
|
||
},
|
||
{
|
||
"name": "SMB shares",
|
||
"endpoints": [
|
||
{"method": "GET", "path": "/api/smb-shares",
|
||
"summary": "List configured SMB shares with connection state and iso counts."},
|
||
{"method": "POST", "path": "/api/smb-shares",
|
||
"summary": "Register an SMB share. Body: { server, share, guest, username?, password?, port? }."},
|
||
{"method": "DELETE", "path": "/api/smb-shares/{id}",
|
||
"summary": "Forget a share and drop its entries from the ISO store."},
|
||
{"method": "POST", "path": "/api/smb-shares/{id}/scan",
|
||
"summary": "Re-list a share for new ISOs."},
|
||
],
|
||
},
|
||
{
|
||
"name": "NFS shares",
|
||
"endpoints": [
|
||
{"method": "GET", "path": "/api/nfs-shares",
|
||
"summary": "List configured NFSv3 shares with connection state and iso counts."},
|
||
{"method": "POST", "path": "/api/nfs-shares",
|
||
"summary": "Register an NFSv3 share. Body: { server, export, port? }. Auth is AUTH_SYS only; access control is by client IP on the server side."},
|
||
{"method": "DELETE", "path": "/api/nfs-shares/{id}",
|
||
"summary": "Forget a share and drop its entries from the ISO store."},
|
||
{"method": "POST", "path": "/api/nfs-shares/{id}/scan",
|
||
"summary": "Re-list a share for new ISOs."},
|
||
],
|
||
},
|
||
{
|
||
"name": "SFTP shares",
|
||
"endpoints": [
|
||
{"method": "GET", "path": "/api/sftp-shares",
|
||
"summary": "List configured SFTP-over-SSH shares with connection state and iso counts."},
|
||
{"method": "POST", "path": "/api/sftp-shares",
|
||
"summary": "Register an SFTP share. Body: { server, export, username, port?, password? | private_key? + passphrase? }. The server's SSH host key is pinned trust-on-first-use."},
|
||
{"method": "DELETE", "path": "/api/sftp-shares/{id}",
|
||
"summary": "Forget a share, drop its entries from the ISO store, and scrub its credentials file."},
|
||
{"method": "POST", "path": "/api/sftp-shares/{id}/scan",
|
||
"summary": "Re-list a share for new ISOs."},
|
||
],
|
||
},
|
||
{
|
||
"name": "Network",
|
||
"endpoints": [
|
||
{"method": "GET", "path": "/api/network",
|
||
"summary": "Detected NIC, server IP, subnet, gateway, advertised base URL."},
|
||
{"method": "PUT", "path": "/api/network",
|
||
"summary": "Update the informational DNS server hint (does not run DNS)."},
|
||
],
|
||
},
|
||
{
|
||
"name": "Settings",
|
||
"endpoints": [
|
||
{"method": "GET", "path": "/api/settings",
|
||
"summary": "Current runtime settings (Windows toggle, timeout, dns hint, …)."},
|
||
{"method": "PUT", "path": "/api/settings",
|
||
"summary": "Replace runtime settings. Guards against enabling Windows when wimboot isn't bundled."},
|
||
{"method": "POST", "path": "/api/branding/logo/{slot}",
|
||
"summary": "Upload a custom logo for a slot (light | dark | client). Multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB. The client slot is raster-only."},
|
||
{"method": "DELETE", "path": "/api/branding/logo/{slot}",
|
||
"summary": "Remove the custom logo for a slot and revert to the bundled mark."},
|
||
{"method": "GET", "path": "/branding/pxe-logo",
|
||
"summary": "Raster form of the operator's 'client' logo for the iPXE menu's `console --picture`. Default background when unset/SVG."},
|
||
{"method": "GET", "path": "/api/sso",
|
||
"summary": "Current SAML SSO configuration."},
|
||
{"method": "PUT", "path": "/api/sso",
|
||
"summary": "Replace SAML SSO configuration. Body: { enabled, idp_name, metadata, metadata_url }."},
|
||
{"method": "GET", "path": "/api/docs",
|
||
"summary": "This API reference."},
|
||
],
|
||
},
|
||
{
|
||
"name": "Auth (Forms)",
|
||
"endpoints": [
|
||
{"method": "POST", "path": "/api/setup",
|
||
"summary": "First-run admin bootstrap. Body: { username, password }. Refuses after the admin exists."},
|
||
{"method": "POST", "path": "/api/login",
|
||
"summary": "Sign in. Body: { username, password }. Sets the openpxe_session cookie."},
|
||
{"method": "POST", "path": "/api/logout",
|
||
"summary": "Revoke the current session and clear the cookie."},
|
||
{"method": "GET", "path": "/api/me",
|
||
"summary": "Auth status — { setup_required, authenticated, user }. Always 200."},
|
||
{"method": "PUT", "path": "/api/me/credentials",
|
||
"summary": "Rotate the admin's credentials. Body: { current_password, new_username?, new_password? }. Revokes all other sessions on success."},
|
||
],
|
||
},
|
||
{
|
||
"name": "Storage telemetry",
|
||
"endpoints": [
|
||
{"method": "GET", "path": "/api/storage/disk",
|
||
"summary": "Free / used / total bytes for the volume hosting the ISO directory."},
|
||
],
|
||
},
|
||
{
|
||
"name": "Unattended answer files",
|
||
"endpoints": [
|
||
{"method": "GET", "path": "/api/unattended",
|
||
"summary": "List uploaded answer files (Kickstart / Preseed / Autoinstall / Windows answer file)."},
|
||
{"method": "POST", "path": "/api/unattended",
|
||
"summary": "Upload an answer file (multipart 'file', .ks/.cfg/.seed/.yaml/.yml/.xml/user-data, up to 1 MB)."},
|
||
{"method": "DELETE", "path": "/api/unattended/{id}",
|
||
"summary": "Delete an uploaded answer file."},
|
||
{"method": "GET", "path": "/unattended/{id}",
|
||
"summary": "Public: serve an answer file with {{HOSTNAME}}/{{IP}}/{{MAC}} substituted from the query string."},
|
||
],
|
||
},
|
||
{
|
||
"name": "Queued Deployment",
|
||
"endpoints": [
|
||
{"method": "GET", "path": "/api/queue",
|
||
"summary": "List queue entries (waiting + assigned, with any deployment profile)."},
|
||
{"method": "POST", "path": "/api/queue/assign",
|
||
"summary": "Assign a target image to queued clients. Body: { target, entry_ids }."},
|
||
{"method": "PUT", "path": "/api/queue/{entry_id}/profile",
|
||
"summary": "Set a queued device's deployment profile. Body: { auto_hostname?, auto_ip?, unattended_file? }."},
|
||
{"method": "DELETE", "path": "/api/queue/{entry_id}",
|
||
"summary": "Release a queue entry without assigning."},
|
||
],
|
||
},
|
||
{
|
||
"name": "Hosts & boot log",
|
||
"endpoints": [
|
||
{"method": "GET", "path": "/api/hosts",
|
||
"summary": "List per-MAC boot bindings."},
|
||
{"method": "POST", "path": "/api/hosts",
|
||
"summary": "Pin a MAC to a boot target. Body: { mac, target, label, auto_hostname?, auto_ip?, unattended_file? }."},
|
||
{"method": "DELETE", "path": "/api/hosts/{mac}",
|
||
"summary": "Remove a binding."},
|
||
{"method": "POST", "path": "/api/hosts/{mac}/wol",
|
||
"summary": "Send a Wake-on-LAN magic packet to a bound MAC (limited + subnet broadcast)."},
|
||
{"method": "GET", "path": "/api/boot-log",
|
||
"summary": "Ring of recent boot events (timestamp, mac, ip, target)."},
|
||
],
|
||
},
|
||
{
|
||
"name": "Notifications & updates",
|
||
"endpoints": [
|
||
{"method": "GET", "path": "/api/notify",
|
||
"summary": "Current notification config (SMTP password redacted)."},
|
||
{"method": "PUT", "path": "/api/notify",
|
||
"summary": "Replace notification config. Body: { enabled, kind, webhook_url, smtp_* }."},
|
||
{"method": "POST", "path": "/api/notify/test",
|
||
"summary": "Send a test notification using the saved config."},
|
||
{"method": "GET", "path": "/api/updates/check",
|
||
"summary": "Compare the running version against the latest Gitea release."},
|
||
],
|
||
},
|
||
{
|
||
"name": "Operator console",
|
||
"endpoints": [
|
||
{"method": "GET", "path": "/api/clients",
|
||
"summary": "Live PXE-client registry — MAC, last IP, arch, events."},
|
||
{"method": "GET", "path": "/api/log/recent",
|
||
"summary": "Ring of recent server log lines for the Terminal tab."},
|
||
{"method": "GET", "path": "/api/log/stream",
|
||
"summary": "Server-Sent Events stream of log lines."},
|
||
{"method": "POST", "path": "/api/terminal",
|
||
"summary": "Run a whitelisted operator command. Body: { command }."},
|
||
],
|
||
},
|
||
],
|
||
}))
|
||
}
|
||
|
||
async fn api_upload_iso(State(state): State<AppState>, mut multipart: Multipart) -> Response {
|
||
// Walk multipart parts until we find the file. Each branch logs so an
|
||
// operator chasing a "stuck" upload in the Terminal tab can see
|
||
// exactly which stage failed (no field, wrong field name, parser
|
||
// error, mid-stream drop, sha mismatch on finish, etc.).
|
||
loop {
|
||
let field_res = multipart.next_field().await;
|
||
match field_res {
|
||
Ok(Some(mut field)) => {
|
||
if field.name() != Some("file") {
|
||
tracing::debug!(
|
||
target: "openpxe::http::upload",
|
||
field = field.name().unwrap_or("?"),
|
||
"skipping non-file multipart part"
|
||
);
|
||
continue;
|
||
}
|
||
let filename = field.file_name().unwrap_or("uploaded.iso").to_string();
|
||
if !filename.to_ascii_lowercase().ends_with(".iso") {
|
||
tracing::warn!(
|
||
target: "openpxe::http::upload",
|
||
filename = %filename, "rejecting non-.iso upload"
|
||
);
|
||
return (StatusCode::BAD_REQUEST, "only .iso uploads accepted").into_response();
|
||
}
|
||
tracing::info!(
|
||
target: "openpxe::http::upload",
|
||
filename = %filename, "upload started"
|
||
);
|
||
let mut handle = match state.iso_store.begin_upload(&filename).await {
|
||
Ok(h) => h,
|
||
Err(e) => {
|
||
tracing::warn!(
|
||
target: "openpxe::http::upload",
|
||
filename = %filename, error = %e,
|
||
"begin_upload rejected (likely duplicate name)"
|
||
);
|
||
return (StatusCode::CONFLICT, format!("{e}")).into_response();
|
||
}
|
||
};
|
||
// Streamed reader loop. We use an explicit `match` instead
|
||
// of `while let Ok(Some(_))` so a mid-stream `Err(_)` (a
|
||
// truncated body from a reverse proxy 524 / network drop)
|
||
// is treated as a failure rather than silently completing
|
||
// with a partial file.
|
||
let mut bytes: u64 = 0;
|
||
let mut next_log_at: u64 = 64 * 1024 * 1024;
|
||
loop {
|
||
match field.chunk().await {
|
||
Ok(Some(chunk)) => {
|
||
if let Err(e) = handle.write_chunk(&chunk).await {
|
||
tracing::error!(
|
||
target: "openpxe::http::upload",
|
||
filename = %filename, bytes,
|
||
error = %e, "write_chunk failed; aborting"
|
||
);
|
||
let _ = handle.abort().await;
|
||
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}"))
|
||
.into_response();
|
||
}
|
||
bytes += chunk.len() as u64;
|
||
if bytes >= next_log_at {
|
||
tracing::info!(
|
||
target: "openpxe::http::upload",
|
||
filename = %filename,
|
||
received_bytes = bytes,
|
||
"upload streaming"
|
||
);
|
||
// Backoff log cadence: 64 MB, 128, 256, …
|
||
next_log_at = next_log_at.saturating_mul(2);
|
||
}
|
||
}
|
||
Ok(None) => break,
|
||
Err(e) => {
|
||
tracing::error!(
|
||
target: "openpxe::http::upload",
|
||
filename = %filename, received_bytes = bytes,
|
||
error = %e,
|
||
"multipart stream ended with error (likely client \
|
||
disconnect or reverse-proxy buffer cap); aborting"
|
||
);
|
||
let _ = handle.abort().await;
|
||
return (
|
||
StatusCode::BAD_REQUEST,
|
||
format!(
|
||
"upload truncated after {bytes} bytes: {e}. \
|
||
If you went through a reverse proxy, try the \
|
||
LAN IP directly — large body buffering caps \
|
||
(Cloudflare free tier is 100 MB) commonly \
|
||
cause this."
|
||
),
|
||
)
|
||
.into_response();
|
||
}
|
||
}
|
||
}
|
||
tracing::info!(
|
||
target: "openpxe::http::upload",
|
||
filename = %filename, received_bytes = bytes,
|
||
"upload body complete; introspecting"
|
||
);
|
||
let meta = match handle.finish(&state.iso_store).await {
|
||
Ok(m) => m,
|
||
Err(e) => {
|
||
tracing::error!(
|
||
target: "openpxe::http::upload",
|
||
filename = %filename, error = %e,
|
||
"finish failed (rename/introspect)"
|
||
);
|
||
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response();
|
||
}
|
||
};
|
||
tracing::info!(
|
||
target: "openpxe::http::upload",
|
||
iso = %meta.id, size = meta.size_bytes,
|
||
family = ?meta.introspection.family,
|
||
entries = meta.boot_entries.len(),
|
||
"upload finished"
|
||
);
|
||
return (StatusCode::CREATED, Json(meta)).into_response();
|
||
}
|
||
Ok(None) => {
|
||
tracing::warn!(target: "openpxe::http::upload", "upload had no 'file' part");
|
||
return (StatusCode::BAD_REQUEST, "no 'file' part").into_response();
|
||
}
|
||
Err(e) => {
|
||
tracing::error!(
|
||
target: "openpxe::http::upload",
|
||
error = %e,
|
||
"multipart parser error before reading any field"
|
||
);
|
||
return (
|
||
StatusCode::BAD_REQUEST,
|
||
format!("multipart parse error: {e}"),
|
||
)
|
||
.into_response();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct UploadBeginBody {
|
||
filename: String,
|
||
#[serde(default)]
|
||
size_bytes: Option<u64>,
|
||
}
|
||
|
||
async fn api_upload_begin(
|
||
State(state): State<AppState>,
|
||
Json(body): Json<UploadBeginBody>,
|
||
) -> Response {
|
||
match state
|
||
.uploads
|
||
.begin(&state.iso_store, &body.filename, body.size_bytes)
|
||
.await
|
||
{
|
||
Ok(started) => {
|
||
tracing::info!(
|
||
target: "openpxe::http::upload",
|
||
upload_id = %started.upload_id,
|
||
iso = %started.iso_id,
|
||
filename = %started.filename,
|
||
expected_size = ?body.size_bytes,
|
||
"chunked upload started"
|
||
);
|
||
(StatusCode::CREATED, Json(started)).into_response()
|
||
}
|
||
Err(Error::Invalid(e)) if e.contains("already exists") => {
|
||
(StatusCode::CONFLICT, e).into_response()
|
||
}
|
||
Err(Error::Invalid(e)) => (StatusCode::BAD_REQUEST, e).into_response(),
|
||
Err(e) => {
|
||
tracing::error!(target: "openpxe::http::upload", error = %e, "chunked upload begin failed");
|
||
(StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response()
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn api_upload_chunk(
|
||
State(state): State<AppState>,
|
||
AxumPath(upload_id): AxumPath<String>,
|
||
headers: HeaderMap,
|
||
chunk: Bytes,
|
||
) -> Response {
|
||
let Some(offset) = parse_u64_header(&headers, "x-openpxe-upload-offset") else {
|
||
return (
|
||
StatusCode::BAD_REQUEST,
|
||
"missing or invalid x-openpxe-upload-offset",
|
||
)
|
||
.into_response();
|
||
};
|
||
let complete = bool_header(&headers, "x-openpxe-upload-complete");
|
||
|
||
match state
|
||
.uploads
|
||
.append(&state.iso_store, &upload_id, offset, chunk, complete)
|
||
.await
|
||
{
|
||
Ok(crate::uploads::UploadAppend::Progress { offset }) => (
|
||
StatusCode::ACCEPTED,
|
||
Json(json!({
|
||
"ok": true,
|
||
"upload_id": upload_id,
|
||
"offset": offset,
|
||
"complete": false,
|
||
})),
|
||
)
|
||
.into_response(),
|
||
Ok(crate::uploads::UploadAppend::Complete { offset, iso }) => {
|
||
tracing::info!(
|
||
target: "openpxe::http::upload",
|
||
upload_id = %upload_id,
|
||
iso = %iso.id,
|
||
size = iso.size_bytes,
|
||
family = ?iso.introspection.family,
|
||
entries = iso.boot_entries.len(),
|
||
"chunked upload finished"
|
||
);
|
||
(
|
||
StatusCode::CREATED,
|
||
Json(json!({
|
||
"ok": true,
|
||
"upload_id": upload_id,
|
||
"offset": offset,
|
||
"complete": true,
|
||
"iso": iso,
|
||
})),
|
||
)
|
||
.into_response()
|
||
}
|
||
Err(Error::Invalid(e)) if e.starts_with("expected offset") => {
|
||
(StatusCode::CONFLICT, e).into_response()
|
||
}
|
||
Err(Error::Invalid(e)) if e.starts_with("no such upload") => {
|
||
(StatusCode::NOT_FOUND, e).into_response()
|
||
}
|
||
Err(Error::Invalid(e)) => (StatusCode::BAD_REQUEST, e).into_response(),
|
||
Err(e) => {
|
||
tracing::error!(
|
||
target: "openpxe::http::upload",
|
||
upload_id = %upload_id,
|
||
error = %e,
|
||
"chunked upload failed"
|
||
);
|
||
(StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response()
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn api_upload_abort(
|
||
State(state): State<AppState>,
|
||
AxumPath(upload_id): AxumPath<String>,
|
||
) -> Response {
|
||
match state.uploads.abort(&upload_id).await {
|
||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||
Err(Error::Invalid(e)) if e.starts_with("no such upload") => {
|
||
(StatusCode::NOT_FOUND, e).into_response()
|
||
}
|
||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||
}
|
||
}
|
||
|
||
fn parse_u64_header(headers: &HeaderMap, name: &'static str) -> Option<u64> {
|
||
headers.get(name)?.to_str().ok()?.trim().parse::<u64>().ok()
|
||
}
|
||
|
||
fn bool_header(headers: &HeaderMap, name: &'static str) -> bool {
|
||
headers
|
||
.get(name)
|
||
.and_then(|v| v.to_str().ok())
|
||
.map(str::trim)
|
||
.is_some_and(|v| matches!(v, "1" | "true" | "TRUE" | "yes" | "YES"))
|
||
}
|
||
|
||
// ─── health / readiness ───────────────────────────────────────────────────
|
||
|
||
async fn healthz() -> Response {
|
||
// Simple liveness — HTTP task is responsive. Does not touch storage or
|
||
// other subsystems so we never fail for downstream reasons.
|
||
(
|
||
[(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))],
|
||
"ok\n",
|
||
)
|
||
.into_response()
|
||
}
|
||
|
||
async fn readyz(State(state): State<AppState>) -> Response {
|
||
// Readiness — can we actually serve clients?
|
||
// 1. At least one iPXE binary must be bundled (without one, TFTP 404s
|
||
// and no client boots).
|
||
// 2. The ISO directory must exist and be readable.
|
||
let assets = openpxe_ipxe_assets::list_assets();
|
||
let mut problems: Vec<&str> = Vec::new();
|
||
if assets.is_empty() {
|
||
problems.push("no iPXE binaries bundled (run scripts/fetch-ipxe.sh before building)");
|
||
}
|
||
let iso_dir_ok = state.iso_store.list_ok();
|
||
if !iso_dir_ok {
|
||
problems.push("iso directory not readable");
|
||
}
|
||
if problems.is_empty() {
|
||
(
|
||
[(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))],
|
||
"ready\n",
|
||
)
|
||
.into_response()
|
||
} else {
|
||
let body = format!("not ready:\n- {}\n", problems.join("\n- "));
|
||
(StatusCode::SERVICE_UNAVAILABLE, body).into_response()
|
||
}
|
||
}
|
||
|
||
// ─── clients + status + settings ──────────────────────────────────────────
|
||
|
||
async fn api_list_clients(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||
Json(json!({ "clients": state.clients.list() }))
|
||
}
|
||
|
||
/// Per-theme branding presence, nested under [`StatusResponse::branding`].
|
||
#[derive(Serialize)]
|
||
struct BrandingStatus {
|
||
light: bool,
|
||
dark: bool,
|
||
client: bool,
|
||
rev: u64,
|
||
}
|
||
|
||
/// Dashboard status payload. v0.5.4: this replaced a 25-key hand-built
|
||
/// `json!` blob — the typed struct makes the contract with the WebUI
|
||
/// compile-checked. Field names ARE the JSON keys; do not rename without
|
||
/// updating `crates/webui/src/app.js` (a `full_flow` test guards the set).
|
||
/// `settings` / `smb` / `started_at` embed their own `Serialize` impls so
|
||
/// the wire shape is byte-identical to the previous `json!` output.
|
||
#[derive(Serialize)]
|
||
struct StatusResponse {
|
||
version: &'static str,
|
||
public_base_url: String,
|
||
iso_count: usize,
|
||
client_count: usize,
|
||
queue_count: usize,
|
||
imaging_count: usize,
|
||
waiting_count: usize,
|
||
ipxe_assets: Vec<String>,
|
||
settings: Settings,
|
||
smb: Option<SmbState>,
|
||
smb_share_count: usize,
|
||
smb_share_reachable: usize,
|
||
nfs_share_count: usize,
|
||
nfs_share_reachable: usize,
|
||
sftp_share_count: usize,
|
||
sftp_share_reachable: usize,
|
||
host_bindings: usize,
|
||
custom_logo: bool,
|
||
branding: BrandingStatus,
|
||
unattended_count: usize,
|
||
uptime_secs: i64,
|
||
started_at: time::OffsetDateTime,
|
||
nic_name: String,
|
||
subnet_mask: String,
|
||
gateway: String,
|
||
}
|
||
|
||
async fn api_status(State(state): State<AppState>) -> Json<StatusResponse> {
|
||
let smb = state.smb.as_ref().map(|s| s.snapshot());
|
||
// v0.4.65+v0.4.67: external storage shares — SMB (userspace
|
||
// smbclient) and NFS (in-process nfs3_client). Dashboard tile
|
||
// sums both so operators see a single "reachable shares" number.
|
||
let smb_shares = state.smb_shares.list();
|
||
let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count();
|
||
let nfs_shares = state.nfs_shares.list();
|
||
let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count();
|
||
// v0.5.5: SFTP shares fold into the same "reachable shares" tile.
|
||
let sftp_shares = state.sftp_shares.list();
|
||
let sftp_reachable = sftp_shares.iter().filter(|m| m.reachable).count();
|
||
let isos = state.iso_store.list();
|
||
let clients = state.clients.list();
|
||
let queue_entries = state.queue.list();
|
||
// Phase 4: dashboard tracks "imaging" as queue entries with an assignment
|
||
// already issued — they're the ones actively chaining a boot script.
|
||
let imaging = queue_entries
|
||
.iter()
|
||
.filter(|entry| entry.assigned_target.is_some())
|
||
.count();
|
||
let waiting = queue_entries.len() - imaging;
|
||
// Side-effect: push gauge values out to the Prometheus surface.
|
||
// Doing it here (in the most-frequently-polled endpoint) keeps the
|
||
// gauges fresh without a dedicated scrape-time hook.
|
||
state.metrics.set_iso_count(isos.len() as u64);
|
||
state.metrics.set_client_count(clients.len() as u64);
|
||
state
|
||
.metrics
|
||
.set_queue_counts(queue_entries.len() as u64, imaging as u64);
|
||
state
|
||
.metrics
|
||
.set_nfs_active((smb_reachable + nfs_reachable + sftp_reachable) as u64);
|
||
state.metrics.record_http(openpxe_core::HttpRoute::Api);
|
||
let now = time::OffsetDateTime::now_utc();
|
||
let uptime_secs = (now - state.started_at).whole_seconds().max(0);
|
||
Json(StatusResponse {
|
||
version: env!("CARGO_PKG_VERSION"),
|
||
public_base_url: state.public_base_url.clone(),
|
||
iso_count: isos.len(),
|
||
client_count: clients.len(),
|
||
queue_count: queue_entries.len(),
|
||
imaging_count: imaging,
|
||
waiting_count: waiting,
|
||
ipxe_assets: openpxe_ipxe_assets::list_assets(),
|
||
settings: state.settings.snapshot(),
|
||
smb,
|
||
smb_share_count: smb_shares.len(),
|
||
smb_share_reachable: smb_reachable,
|
||
// v0.4.67: NFSv3 share counts. The dashboard tile sums these with
|
||
// the SMB counts above ("N shares reachable") so the top-line
|
||
// metric works regardless of protocol mix.
|
||
nfs_share_count: nfs_shares.len(),
|
||
nfs_share_reachable: nfs_reachable,
|
||
// v0.5.5: SFTP share counts, summed into the same dashboard tile.
|
||
sftp_share_count: sftp_shares.len(),
|
||
sftp_share_reachable: sftp_reachable,
|
||
host_bindings: state.hosts.len(),
|
||
custom_logo: state.branding.has_any_web_logo(),
|
||
branding: BrandingStatus {
|
||
light: state.branding.has_logo(LogoSlot::Light),
|
||
dark: state.branding.has_logo(LogoSlot::Dark),
|
||
client: state.branding.has_logo(LogoSlot::Client),
|
||
rev: state.branding.logo_rev(),
|
||
},
|
||
unattended_count: state.unattended.len(),
|
||
uptime_secs,
|
||
started_at: state.started_at,
|
||
nic_name: state.nic_name.clone(),
|
||
subnet_mask: state.subnet_mask.clone(),
|
||
gateway: state.gateway.clone(),
|
||
})
|
||
}
|
||
|
||
async fn api_get_settings(State(state): State<AppState>) -> Json<Settings> {
|
||
Json(state.settings.snapshot())
|
||
}
|
||
|
||
async fn api_put_settings(
|
||
State(state): State<AppState>,
|
||
Json(mut new): Json<Settings>,
|
||
) -> Response {
|
||
// Guardrail: Windows boot requires the wimboot shim to be bundled.
|
||
// Without it, clients chain a non-existent /ipxe/wimboot and stall.
|
||
if new.windows_enabled {
|
||
let assets = openpxe_ipxe_assets::list_assets();
|
||
if !assets.iter().any(|n| n == "wimboot") {
|
||
return (
|
||
StatusCode::BAD_REQUEST,
|
||
"cannot enable Windows: 'wimboot' binary is not bundled. \
|
||
Place a signed wimboot build at assets/ipxe/wimboot and rebuild \
|
||
the container. See docs/architecture.md for details.",
|
||
)
|
||
.into_response();
|
||
}
|
||
}
|
||
new.smb_host_override = new.smb_host_override.trim().to_string();
|
||
|
||
// Detect whether this PUT changes the Windows toggle, so we only
|
||
// restart smbd when it actually flipped.
|
||
let was_enabled = state.settings.snapshot().windows_enabled;
|
||
let want_enabled = new.windows_enabled;
|
||
state.settings.replace(new);
|
||
|
||
if let Some(smb) = &state.smb {
|
||
match (was_enabled, want_enabled) {
|
||
(false, true) => {
|
||
let _ = smb.start();
|
||
}
|
||
(true, false) => {
|
||
smb.stop();
|
||
}
|
||
(true, true) => {
|
||
let _ = smb.reconcile();
|
||
}
|
||
(false, false) => {}
|
||
}
|
||
}
|
||
StatusCode::NO_CONTENT.into_response()
|
||
}
|
||
|
||
// ─── Queued Deployment API ─────────────────────────────────────────────────
|
||
|
||
async fn api_list_queue(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||
Json(json!({
|
||
"entries": state.queue.list(),
|
||
"count": state.queue.list().len(),
|
||
}))
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct QueueJoinParams {
|
||
/// Client MAC from iPXE's `${mac}` variable. iPXE substitutes before
|
||
/// the HTTP request so we receive a plain colon-separated MAC.
|
||
mac: Option<String>,
|
||
}
|
||
|
||
/// Called by iPXE via `chain --replace`. We respond with a tiny iPXE
|
||
/// script that hard-loops on `/api/queue/poll/<id>`. iPXE keeps fetching
|
||
/// until poll returns an actual boot script.
|
||
async fn api_queue_join(
|
||
State(state): State<AppState>,
|
||
Query(p): Query<QueueJoinParams>,
|
||
headers: HeaderMap,
|
||
) -> Response {
|
||
let mac = p.mac.unwrap_or_else(|| "unknown".to_string());
|
||
let ip = headers
|
||
.get("x-forwarded-for")
|
||
.and_then(|h| h.to_str().ok())
|
||
.and_then(|s| s.split(',').next())
|
||
.and_then(|s| s.trim().parse().ok());
|
||
|
||
let queue_entry = state.queue.join(&mac, ip, None);
|
||
state.clients.record(
|
||
&mac,
|
||
ip,
|
||
None,
|
||
ClientEvent::HttpScriptFetch {
|
||
target: "queue-join".into(),
|
||
},
|
||
);
|
||
|
||
let base = &state.public_base_url;
|
||
// ASCII only - some iPXE builds on firmware consoles mangle non-ASCII.
|
||
let script = format!(
|
||
"#!ipxe\n\
|
||
echo\n\
|
||
echo ==========================================\n\
|
||
echo Queued Deployment - Queue Position {}\n\
|
||
echo Waiting for operator to assign an image\n\
|
||
echo (Ctrl-B returns to the iPXE shell)\n\
|
||
echo ==========================================\n\
|
||
chain {base}/api/queue/poll/{}\n",
|
||
queue_entry.position, queue_entry.id
|
||
);
|
||
text_plain(script)
|
||
}
|
||
|
||
/// Long-poll endpoint. Waits up to 25s for an assignment; if none, returns
|
||
/// a script that loops back to itself. 25s keeps us well inside typical
|
||
/// HTTP idle timeouts for iPXE and intermediaries.
|
||
async fn api_queue_poll(
|
||
State(state): State<AppState>,
|
||
AxumPath(entry_id): AxumPath<String>,
|
||
) -> Response {
|
||
let Some(notify) = state.queue.notifier(&entry_id) else {
|
||
// Queue entry was released; send client back to the main menu.
|
||
let base = &state.public_base_url;
|
||
return text_plain(format!("#!ipxe\nchain {base}/boot.ipxe\n"));
|
||
};
|
||
|
||
// Wait for an assignment or timeout.
|
||
let _ = tokio::time::timeout(Duration::from_secs(25), notify.notified()).await;
|
||
|
||
let snap = state.queue.touch(&entry_id);
|
||
let base = &state.public_base_url;
|
||
match snap {
|
||
// Bind `target` directly so we can't observe an Option::None between
|
||
// the guard and the unwrap (the old code had a race with concurrent
|
||
// `release`). We also do NOT release the queue entry here — the web UI
|
||
// operator releases it explicitly, which keeps a record of "this
|
||
// machine was assigned image X" visible until the client is known
|
||
// to have started. Clients that retry on transient network errors
|
||
// still get a valid boot script instead of falling back to the
|
||
// menu.
|
||
Some(g) if g.assigned_target.is_some() => {
|
||
let target = g.assigned_target.clone().unwrap_or_default();
|
||
tracing::info!(
|
||
target: "openpxe::queue",
|
||
entry_id=%entry_id, mac=%g.mac, target=%target,
|
||
"queue assignment delivered"
|
||
);
|
||
// Carry `?mac=` so the per-entry handler can resolve this
|
||
// device's deployment profile (auto hostname/IP + unattended
|
||
// file) and inject the unattended kernel args, mirroring the
|
||
// pinned-host path.
|
||
let qmac = g.mac.clone();
|
||
text_plain(format!(
|
||
"#!ipxe\n\
|
||
echo Queue assignment received: {target}\n\
|
||
chain {base}/boot/{target}.ipxe?mac={qmac} || chain {base}/api/queue/poll/{entry_id}\n"
|
||
))
|
||
}
|
||
Some(g) => {
|
||
// No assignment yet - loop and re-poll. Repaint position so the
|
||
// UI count stays accurate if other queue entries were released meanwhile.
|
||
text_plain(format!(
|
||
"#!ipxe\n\
|
||
echo Queue Position {} - still waiting\n\
|
||
chain {base}/api/queue/poll/{entry_id}\n",
|
||
g.position
|
||
))
|
||
}
|
||
None => text_plain(format!("#!ipxe\nchain {base}/boot.ipxe\n")),
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct QueueAssignBody {
|
||
/// Boot entry id (from `BootEntry::id`). Same one used in
|
||
/// `/boot/<id>.ipxe`.
|
||
target: String,
|
||
/// Queue entry ids to assign. Empty = assign to all currently queued clients.
|
||
entry_ids: Vec<String>,
|
||
}
|
||
|
||
async fn api_queue_assign(
|
||
State(state): State<AppState>,
|
||
Json(body): Json<QueueAssignBody>,
|
||
) -> Json<serde_json::Value> {
|
||
let ids = if body.entry_ids.is_empty() {
|
||
state
|
||
.queue
|
||
.list()
|
||
.into_iter()
|
||
.map(|g| g.id)
|
||
.collect::<Vec<_>>()
|
||
} else {
|
||
body.entry_ids
|
||
};
|
||
// Guard: target must exist as a BootEntry id.
|
||
let found = state
|
||
.iso_store
|
||
.list()
|
||
.into_iter()
|
||
.any(|i| i.boot_entries.iter().any(|e| e.id == body.target));
|
||
if !found {
|
||
return Json(json!({ "ok": false, "error": "unknown target" }));
|
||
}
|
||
let n = state.queue.assign(&ids, &body.target);
|
||
Json(json!({ "ok": true, "assigned": n, "target": body.target }))
|
||
}
|
||
|
||
async fn api_queue_set_profile(
|
||
State(state): State<AppState>,
|
||
AxumPath(entry_id): AxumPath<String>,
|
||
Json(body): Json<DeployProfile>,
|
||
) -> Response {
|
||
let profile = body.normalized();
|
||
if let Err(msg) = validate_profile(&state, &profile) {
|
||
return (StatusCode::BAD_REQUEST, msg).into_response();
|
||
}
|
||
match state.queue.set_profile(&entry_id, profile) {
|
||
Some(entry) => (StatusCode::OK, Json(entry)).into_response(),
|
||
None => (StatusCode::NOT_FOUND, "no such queue entry").into_response(),
|
||
}
|
||
}
|
||
|
||
async fn api_queue_release(
|
||
State(state): State<AppState>,
|
||
AxumPath(entry_id): AxumPath<String>,
|
||
) -> StatusCode {
|
||
match state.queue.release(&entry_id) {
|
||
Some(_) => StatusCode::NO_CONTENT,
|
||
None => StatusCode::NOT_FOUND,
|
||
}
|
||
}
|
||
|
||
// ─── SMB share API (v0.4.65) ───────────────────────────────────────────────
|
||
//
|
||
// Replaces the NFS share manager from v0.4.64. The wire shape is similar
|
||
// — a {shares: [...]} list, a POST that returns either the share or a
|
||
// structured {error, stderr, hint} body — so the UI can render both the
|
||
// same way.
|
||
|
||
async fn api_smb_shares_list(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||
Json(json!({ "shares": state.smb_shares.list() }))
|
||
}
|
||
|
||
async fn api_smb_shares_add(
|
||
State(state): State<AppState>,
|
||
Json(req): Json<SmbAddRequest>,
|
||
) -> Response {
|
||
match state.smb_shares.add(req).await {
|
||
Ok(s) => (StatusCode::CREATED, Json(s)).into_response(),
|
||
Err(err) => (StatusCode::BAD_REQUEST, Json(err)).into_response(),
|
||
}
|
||
}
|
||
|
||
async fn api_smb_shares_remove(
|
||
State(state): State<AppState>,
|
||
AxumPath(id): AxumPath<String>,
|
||
) -> Response {
|
||
match state.smb_shares.remove(&id).await {
|
||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||
}
|
||
}
|
||
|
||
async fn api_smb_shares_scan(
|
||
State(state): State<AppState>,
|
||
AxumPath(id): AxumPath<String>,
|
||
) -> Response {
|
||
match state.smb_shares.rescan(&id).await {
|
||
Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(),
|
||
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
||
}
|
||
}
|
||
|
||
// ─── NFS share API (v0.4.67) ───────────────────────────────────────────────
|
||
//
|
||
// Parallel to the SMB shares API. The pure-Rust NFSv3 client
|
||
// (`nfs3_client`) gives us in-process listing and streaming, no
|
||
// subprocess. Unlike SMB, NFS-sourced ISOs support HTTP Range
|
||
// requests — NFSv3 READ3 takes an explicit offset.
|
||
|
||
async fn api_nfs_shares_list(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||
Json(json!({ "shares": state.nfs_shares.list() }))
|
||
}
|
||
|
||
async fn api_nfs_shares_add(
|
||
State(state): State<AppState>,
|
||
Json(req): Json<NfsAddRequest>,
|
||
) -> Response {
|
||
match state.nfs_shares.add(req).await {
|
||
Ok(s) => (StatusCode::CREATED, Json(s)).into_response(),
|
||
Err(err) => (StatusCode::BAD_REQUEST, Json(err)).into_response(),
|
||
}
|
||
}
|
||
|
||
async fn api_nfs_shares_remove(
|
||
State(state): State<AppState>,
|
||
AxumPath(id): AxumPath<String>,
|
||
) -> Response {
|
||
match state.nfs_shares.remove(&id).await {
|
||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||
}
|
||
}
|
||
|
||
async fn api_nfs_shares_scan(
|
||
State(state): State<AppState>,
|
||
AxumPath(id): AxumPath<String>,
|
||
) -> Response {
|
||
match state.nfs_shares.rescan(&id).await {
|
||
Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(),
|
||
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
||
}
|
||
}
|
||
|
||
// ─── SFTP share API (v0.5.5) ───────────────────────────────────────────────
|
||
//
|
||
// Parallel to the NFS shares API. The pure-Rust `russh` + `russh-sftp`
|
||
// client gives us in-process listing and streaming, no subprocess. Like
|
||
// NFS (and unlike SMB), SFTP-sourced ISOs support HTTP Range requests —
|
||
// SFTP opens a seekable file handle. Auth is password OR SSH private
|
||
// key; the server's host key is pinned trust-on-first-use.
|
||
|
||
async fn api_sftp_shares_list(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||
Json(json!({ "shares": state.sftp_shares.list() }))
|
||
}
|
||
|
||
async fn api_sftp_shares_add(
|
||
State(state): State<AppState>,
|
||
Json(req): Json<SftpAddRequest>,
|
||
) -> Response {
|
||
match state.sftp_shares.add(req).await {
|
||
Ok(s) => (StatusCode::CREATED, Json(s)).into_response(),
|
||
Err(err) => (StatusCode::BAD_REQUEST, Json(err)).into_response(),
|
||
}
|
||
}
|
||
|
||
async fn api_sftp_shares_remove(
|
||
State(state): State<AppState>,
|
||
AxumPath(id): AxumPath<String>,
|
||
) -> Response {
|
||
match state.sftp_shares.remove(&id).await {
|
||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||
}
|
||
}
|
||
|
||
async fn api_sftp_shares_scan(
|
||
State(state): State<AppState>,
|
||
AxumPath(id): AxumPath<String>,
|
||
) -> Response {
|
||
match state.sftp_shares.rescan(&id).await {
|
||
Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(),
|
||
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
||
}
|
||
}
|
||
|
||
// ─── Network info API ──────────────────────────────────────────────────────
|
||
|
||
async fn api_network(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||
Json(json!({
|
||
"server_ip": state.public_base_url
|
||
.strip_prefix("http://")
|
||
.unwrap_or(&state.public_base_url),
|
||
"nic_name": state.nic_name,
|
||
"subnet_mask": state.subnet_mask,
|
||
"gateway": state.gateway,
|
||
"dns_server": state.settings.snapshot().dns_server,
|
||
"public_base_url": state.public_base_url,
|
||
}))
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct NetworkPut {
|
||
/// Operators can set or clear an informational DNS hint. Server IP /
|
||
/// NIC / mask / gateway are auto-detected and not editable from the
|
||
/// UI — changing them in the wrong direction would silently break
|
||
/// PXE for every client.
|
||
dns_server: String,
|
||
}
|
||
|
||
async fn api_network_put(
|
||
State(state): State<AppState>,
|
||
Json(body): Json<NetworkPut>,
|
||
) -> StatusCode {
|
||
let mut s = state.settings.snapshot();
|
||
s.dns_server = body.dns_server.trim().to_string();
|
||
state.settings.replace(s);
|
||
StatusCode::NO_CONTENT
|
||
}
|
||
|
||
// ─── Per-MAC host bindings ────────────────────────────────────────────────
|
||
|
||
async fn api_hosts_list(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||
Json(json!({ "hosts": state.hosts.list() }))
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct HostsUpsertBody {
|
||
mac: String,
|
||
target: String,
|
||
#[serde(default)]
|
||
label: String,
|
||
/// v0.5.2: optional unattended-install profile. Flattened so the
|
||
/// front-end posts `auto_hostname` / `auto_ip` / `unattended_file`
|
||
/// at the top level alongside mac/target/label.
|
||
#[serde(default, flatten)]
|
||
profile: DeployProfile,
|
||
}
|
||
|
||
async fn api_hosts_upsert(
|
||
State(state): State<AppState>,
|
||
Json(body): Json<HostsUpsertBody>,
|
||
) -> Response {
|
||
let mac = body.mac.trim();
|
||
if mac.is_empty() {
|
||
return (StatusCode::BAD_REQUEST, "mac is required").into_response();
|
||
}
|
||
// Sanity-check the target if the operator supplied a real boot
|
||
// entry id (anything starting with `_` is a reserved menu shortcut
|
||
// and exists by definition).
|
||
let target = body.target.trim();
|
||
if !target.starts_with('_')
|
||
&& !state
|
||
.iso_store
|
||
.list()
|
||
.into_iter()
|
||
.any(|i| i.boot_entries.iter().any(|e| e.id == target))
|
||
{
|
||
return (
|
||
StatusCode::BAD_REQUEST,
|
||
format!("unknown boot entry: {target}"),
|
||
)
|
||
.into_response();
|
||
}
|
||
let profile = body.profile.normalized();
|
||
if let Err(msg) = validate_profile(&state, &profile) {
|
||
return (StatusCode::BAD_REQUEST, msg).into_response();
|
||
}
|
||
let binding = state.hosts.upsert(mac, target, body.label.trim(), profile);
|
||
(StatusCode::CREATED, Json(binding)).into_response()
|
||
}
|
||
|
||
/// Shared validation for a deployment profile (host pin + queue profile):
|
||
/// the referenced unattended file must exist, and a supplied IP must
|
||
/// parse. Hostname is free-form (installers vary), so we only length-cap
|
||
/// it (done in `DeployProfile::normalized`).
|
||
fn validate_profile(state: &AppState, profile: &DeployProfile) -> Result<(), String> {
|
||
if let Some(id) = profile.unattended_file.as_deref() {
|
||
if state.unattended.get(id).is_none() {
|
||
return Err(format!("unknown unattended file: {id}"));
|
||
}
|
||
}
|
||
if let Some(ip) = profile.auto_ip.as_deref() {
|
||
if ip.parse::<std::net::IpAddr>().is_err() {
|
||
return Err(format!("auto_ip is not a valid IP address: {ip}"));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
async fn api_hosts_remove(
|
||
State(state): State<AppState>,
|
||
AxumPath(mac): AxumPath<String>,
|
||
) -> StatusCode {
|
||
if state.hosts.remove(&mac) {
|
||
StatusCode::NO_CONTENT
|
||
} else {
|
||
StatusCode::NOT_FOUND
|
||
}
|
||
}
|
||
|
||
/// v0.5.0: Wake-on-LAN a bound host. Sends a magic packet to the limited
|
||
/// broadcast (255.255.255.255) and the server's own subnet broadcast
|
||
/// (computed from the advertised IP + detected mask), which covers the
|
||
/// common "same VLAN as OpenPXE" case with zero network config. We only
|
||
/// wake MACs that are actually bound — keeps this from being an open
|
||
/// "spray packets at any MAC" endpoint.
|
||
async fn api_hosts_wol(State(state): State<AppState>, AxumPath(mac): AxumPath<String>) -> Response {
|
||
if state.hosts.lookup(&mac).is_none() {
|
||
return (
|
||
StatusCode::NOT_FOUND,
|
||
"no host binding for that MAC — bind it first",
|
||
)
|
||
.into_response();
|
||
}
|
||
|
||
// Compute the server's subnet broadcast from the advertised IP +
|
||
// detected mask so the packet reaches the right VLAN even if the
|
||
// limited broadcast is filtered. Best-effort: skip if either won't
|
||
// parse.
|
||
let server_ip = state
|
||
.public_base_url
|
||
.strip_prefix("http://")
|
||
.unwrap_or(&state.public_base_url)
|
||
.split(':')
|
||
.next()
|
||
.unwrap_or("")
|
||
.parse::<std::net::Ipv4Addr>();
|
||
let mask = state.subnet_mask.parse::<std::net::Ipv4Addr>();
|
||
let mut broadcasts = Vec::new();
|
||
if let (Ok(ip), Ok(m)) = (server_ip, mask) {
|
||
broadcasts.push(wol::subnet_broadcast(ip, m));
|
||
}
|
||
|
||
// The send is a blocking std UDP call; push it off the async
|
||
// executor.
|
||
let mac_owned = mac.clone();
|
||
let result = tokio::task::spawn_blocking(move || wol::wake(&mac_owned, &broadcasts)).await;
|
||
match result {
|
||
Ok(Ok(n)) => {
|
||
// Fire-and-forget notification — nice "someone woke a box"
|
||
// signal, never blocks the response.
|
||
spawn_notify(
|
||
&state,
|
||
"Wake-on-LAN sent",
|
||
&format!("OpenPXE sent a Wake-on-LAN magic packet to {mac}."),
|
||
);
|
||
Json(json!({ "ok": true, "broadcasts": n })).into_response()
|
||
}
|
||
Ok(Err(e)) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
||
Err(e) => (
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
format!("wol task failed: {e}"),
|
||
)
|
||
.into_response(),
|
||
}
|
||
}
|
||
|
||
// ─── Notifications (v0.5.0) ───────────────────────────────────────────────
|
||
|
||
async fn api_notify_get(State(state): State<AppState>) -> Json<NotifyConfig> {
|
||
// Redact the SMTP password before it leaves the process.
|
||
Json(state.notify.snapshot().redacted())
|
||
}
|
||
|
||
async fn api_notify_put(State(state): State<AppState>, Json(cfg): Json<NotifyConfig>) -> Response {
|
||
match state.notify.replace(cfg) {
|
||
Ok(saved) => (StatusCode::OK, Json(saved.redacted())).into_response(),
|
||
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
||
}
|
||
}
|
||
|
||
/// Send a test notification using the *currently saved* config (not the
|
||
/// request body) so the operator validates exactly what's persisted.
|
||
async fn api_notify_test(State(state): State<AppState>) -> Response {
|
||
let cfg = state.notify.snapshot();
|
||
match crate::notify::send(
|
||
&cfg,
|
||
"OpenPXE test notification",
|
||
"If you're reading this, OpenPXE notifications are wired up correctly. \
|
||
This is a test from the Advanced settings tab.",
|
||
)
|
||
.await
|
||
{
|
||
Ok(()) => Json(json!({ "ok": true })).into_response(),
|
||
Err(e) => (StatusCode::BAD_GATEWAY, e).into_response(),
|
||
}
|
||
}
|
||
|
||
// ─── Update check (v0.5.0) ────────────────────────────────────────────────
|
||
|
||
/// Query the project's Gitea releases API for the latest published tag
|
||
/// and compare it to the running version. This is the only outbound
|
||
/// call OpenPXE makes that isn't operator-initiated data movement, and
|
||
/// it's strictly on-demand (the About tab's "Check for updates" button)
|
||
/// — never a background poll, keeping the air-gapped promise intact.
|
||
async fn api_updates_check() -> Response {
|
||
let current = env!("CARGO_PKG_VERSION");
|
||
let Some(api) = gitea_releases_api_url() else {
|
||
return Json(json!({
|
||
"current": current,
|
||
"error": "repository URL not configured at build time",
|
||
}))
|
||
.into_response();
|
||
};
|
||
|
||
let client = match reqwest::Client::builder()
|
||
.timeout(std::time::Duration::from_secs(8))
|
||
.user_agent(concat!("OpenPXE/", env!("CARGO_PKG_VERSION")))
|
||
.build()
|
||
{
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
return Json(json!({ "current": current, "error": format!("client: {e}") }))
|
||
.into_response();
|
||
}
|
||
};
|
||
|
||
match client.get(&api).send().await {
|
||
Ok(resp) if resp.status().is_success() => {
|
||
let body: serde_json::Value = resp.json().await.unwrap_or(json!({}));
|
||
let latest_tag = body
|
||
.get("tag_name")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("")
|
||
.to_string();
|
||
let html_url = body
|
||
.get("html_url")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("")
|
||
.to_string();
|
||
let update_available = version_is_newer(latest_tag.trim_start_matches('v'), current);
|
||
Json(json!({
|
||
"current": current,
|
||
"latest": latest_tag,
|
||
"update_available": update_available,
|
||
"html_url": html_url,
|
||
}))
|
||
.into_response()
|
||
}
|
||
Ok(resp) => Json(json!({
|
||
"current": current,
|
||
"error": format!("releases API returned HTTP {}", resp.status()),
|
||
}))
|
||
.into_response(),
|
||
Err(e) => Json(json!({
|
||
"current": current,
|
||
"error": format!("could not reach the releases API: {e}"),
|
||
}))
|
||
.into_response(),
|
||
}
|
||
}
|
||
|
||
/// Derive the Gitea `releases/latest` API URL from the compile-time
|
||
/// repository URL (`https://host/owner/repo`).
|
||
fn gitea_releases_api_url() -> Option<String> {
|
||
let repo = option_env!("CARGO_PKG_REPOSITORY").unwrap_or("");
|
||
let rest = repo
|
||
.strip_prefix("https://")
|
||
.or_else(|| repo.strip_prefix("http://"))?;
|
||
let mut parts = rest.trim_end_matches('/').splitn(3, '/');
|
||
let host = parts.next()?;
|
||
let owner = parts.next()?;
|
||
let name = parts.next()?;
|
||
if host.is_empty() || owner.is_empty() || name.is_empty() {
|
||
return None;
|
||
}
|
||
Some(format!(
|
||
"https://{host}/api/v1/repos/{owner}/{name}/releases/latest"
|
||
))
|
||
}
|
||
|
||
/// Compare two dotted numeric versions; `true` when `latest` is strictly
|
||
/// newer than `current`. Non-numeric / malformed parts compare as 0, so
|
||
/// a garbage tag never falsely reports an update.
|
||
fn version_is_newer(latest: &str, current: &str) -> bool {
|
||
fn parts(v: &str) -> Vec<u64> {
|
||
v.split('.')
|
||
.map(|p| {
|
||
p.chars()
|
||
.take_while(char::is_ascii_digit)
|
||
.collect::<String>()
|
||
})
|
||
.map(|s| s.parse::<u64>().unwrap_or(0))
|
||
.collect()
|
||
}
|
||
let (l, c) = (parts(latest), parts(current));
|
||
for i in 0..l.len().max(c.len()) {
|
||
let lv = l.get(i).copied().unwrap_or(0);
|
||
let cv = c.get(i).copied().unwrap_or(0);
|
||
if lv != cv {
|
||
return lv > cv;
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
/// Fire a notification on a detached task. Never blocks the caller and
|
||
/// never surfaces an error — boot/WoL paths must not hinge on a webhook.
|
||
fn spawn_notify(state: &AppState, subject: &str, body: &str) {
|
||
let cfg = state.notify.snapshot();
|
||
if !cfg.is_usable() {
|
||
return;
|
||
}
|
||
let subject = subject.to_string();
|
||
let body = body.to_string();
|
||
tokio::spawn(async move {
|
||
if let Err(e) = crate::notify::send(&cfg, &subject, &body).await {
|
||
tracing::warn!(target: "openpxe::notify", "notification send failed: {e}");
|
||
}
|
||
});
|
||
}
|
||
|
||
// ─── Boot event log ───────────────────────────────────────────────────────
|
||
|
||
async fn api_boot_log(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||
Json(json!({ "events": state.boot_log.list() }))
|
||
}
|
||
|
||
// ─── Prometheus metrics ───────────────────────────────────────────────────
|
||
|
||
async fn api_metrics(State(state): State<AppState>) -> Response {
|
||
// Refresh gauges from live state before rendering — keeps the
|
||
// scrape "honest" without making /api/status the only path that
|
||
// updates them.
|
||
state
|
||
.metrics
|
||
.set_iso_count(state.iso_store.list().len() as u64);
|
||
state
|
||
.metrics
|
||
.set_client_count(state.clients.list().len() as u64);
|
||
let queue_entries = state.queue.list();
|
||
let imaging = queue_entries
|
||
.iter()
|
||
.filter(|entry| entry.assigned_target.is_some())
|
||
.count();
|
||
state
|
||
.metrics
|
||
.set_queue_counts(queue_entries.len() as u64, imaging as u64);
|
||
// v0.4.67: gauge tracks reachable external-storage shares
|
||
// across both protocols (SMB userspace + NFSv3 in-process).
|
||
let smb_ok = state
|
||
.smb_shares
|
||
.list()
|
||
.iter()
|
||
.filter(|m| m.reachable)
|
||
.count();
|
||
let nfs_ok = state
|
||
.nfs_shares
|
||
.list()
|
||
.iter()
|
||
.filter(|m| m.reachable)
|
||
.count();
|
||
// v0.5.5: SFTP shares fold into the same reachable-shares gauge.
|
||
let sftp_ok = state
|
||
.sftp_shares
|
||
.list()
|
||
.iter()
|
||
.filter(|m| m.reachable)
|
||
.count();
|
||
state
|
||
.metrics
|
||
.set_nfs_active((smb_ok + nfs_ok + sftp_ok) as u64);
|
||
|
||
let now = time::OffsetDateTime::now_utc();
|
||
let uptime = (now - state.started_at).whole_seconds().max(0) as u64;
|
||
let body = state.metrics.render(env!("CARGO_PKG_VERSION"), uptime);
|
||
(
|
||
[(
|
||
header::CONTENT_TYPE,
|
||
HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"),
|
||
)],
|
||
body,
|
||
)
|
||
.into_response()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn meta(kind: UnattendedKind) -> UnattendedMeta {
|
||
UnattendedMeta {
|
||
id: "ks1".into(),
|
||
filename: "f".into(),
|
||
kind,
|
||
size_bytes: 0,
|
||
uploaded_at: time::OffsetDateTime::UNIX_EPOCH,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn unattended_kickstart_arg_carries_query() {
|
||
let p = DeployProfile {
|
||
auto_hostname: Some("node7".into()),
|
||
auto_ip: Some("10.0.0.7".into()),
|
||
unattended_file: Some("ks1".into()),
|
||
};
|
||
let a = build_unattended_args(
|
||
"http://h",
|
||
&meta(UnattendedKind::Kickstart),
|
||
Some("aa:bb:cc:dd:ee:ff"),
|
||
&p,
|
||
)
|
||
.unwrap();
|
||
assert!(a.starts_with("inst.ks=http://h/unattended/ks1?"), "{a}");
|
||
assert!(a.contains("hostname=node7"), "{a}");
|
||
assert!(a.contains("ip=10.0.0.7"), "{a}");
|
||
// MAC colons are percent-encoded.
|
||
assert!(a.contains("mac=aa%3Abb%3Acc%3Add%3Aee%3Aff"), "{a}");
|
||
}
|
||
|
||
#[test]
|
||
fn unattended_preseed_appends_hostname_kernel_arg() {
|
||
let p = DeployProfile {
|
||
auto_hostname: Some("deb1".into()),
|
||
..Default::default()
|
||
};
|
||
let a =
|
||
build_unattended_args("http://h/", &meta(UnattendedKind::Preseed), None, &p).unwrap();
|
||
assert!(
|
||
a.starts_with("auto=true priority=critical url=http://h/unattended/ks1"),
|
||
"{a}"
|
||
);
|
||
assert!(a.ends_with(" hostname=deb1"), "{a}");
|
||
}
|
||
|
||
#[test]
|
||
fn unattended_autoinstall_uses_nocloud_seed_dir() {
|
||
let p = DeployProfile {
|
||
auto_hostname: Some("u1".into()),
|
||
auto_ip: Some("10.1.1.5".into()),
|
||
unattended_file: Some("ks1".into()),
|
||
};
|
||
let a = build_unattended_args(
|
||
"http://h",
|
||
&meta(UnattendedKind::Autoinstall),
|
||
Some("aa:bb"),
|
||
&p,
|
||
)
|
||
.unwrap();
|
||
assert!(
|
||
a.starts_with("autoinstall ds=nocloud-net;s=http://h/unattended/ks1/"),
|
||
"{a}"
|
||
);
|
||
assert!(a.ends_with('/'), "seed URL must end with '/': {a}");
|
||
// The ctx segment round-trips back to the per-host values.
|
||
let ctx = a.trim_end_matches('/').rsplit('/').next().unwrap();
|
||
let (mac, host, ip) = decode_seed_ctx(ctx);
|
||
assert_eq!(mac.as_deref(), Some("aa:bb"));
|
||
assert_eq!(host.as_deref(), Some("u1"));
|
||
assert_eq!(ip.as_deref(), Some("10.1.1.5"));
|
||
}
|
||
|
||
#[test]
|
||
fn windows_answer_file_is_not_injected() {
|
||
let p = DeployProfile {
|
||
unattended_file: Some("ks1".into()),
|
||
..Default::default()
|
||
};
|
||
assert!(
|
||
build_unattended_args("http://h", &meta(UnattendedKind::AnswerFile), None, &p)
|
||
.is_none()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn seed_ctx_empty_segment_decodes_to_none() {
|
||
let ctx = encode_seed_ctx(None, None, None);
|
||
let (m, h, i) = decode_seed_ctx(&ctx);
|
||
assert!(m.is_none() && h.is_none() && i.is_none());
|
||
// Garbage decodes safely to all-None.
|
||
let (m2, h2, i2) = decode_seed_ctx("!!!not-base64!!!");
|
||
assert!(m2.is_none() && h2.is_none() && i2.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn pct_encode_escapes_reserved() {
|
||
assert_eq!(pct_encode("aa:bb cc"), "aa%3Abb%20cc");
|
||
assert_eq!(pct_encode("node-7.lab_1~"), "node-7.lab_1~");
|
||
}
|
||
|
||
#[test]
|
||
fn version_newer_detects_updates() {
|
||
assert!(version_is_newer("0.5.1", "0.5.0"));
|
||
assert!(version_is_newer("0.6.0", "0.5.9"));
|
||
assert!(version_is_newer("1.0.0", "0.9.9"));
|
||
assert!(!version_is_newer("0.5.0", "0.5.0"));
|
||
assert!(!version_is_newer("0.4.69", "0.5.0"));
|
||
// A garbage / empty tag must never falsely report an update.
|
||
assert!(!version_is_newer("", "0.5.0"));
|
||
assert!(!version_is_newer("not-a-version", "0.5.0"));
|
||
}
|
||
|
||
#[test]
|
||
fn gitea_api_url_derives_from_repo() {
|
||
// The crate inherits the workspace `repository`, so
|
||
// CARGO_PKG_REPOSITORY is populated and the update check has a
|
||
// real URL to hit (regressed once when the inherit was missing).
|
||
let u = gitea_releases_api_url().expect("repository must be configured at build time");
|
||
assert!(u.contains("/api/v1/repos/"), "got: {u}");
|
||
assert!(u.ends_with("/releases/latest"), "got: {u}");
|
||
}
|
||
|
||
#[test]
|
||
fn range_full() {
|
||
let (s, e, p) = parse_range(None, 1000).unwrap();
|
||
assert_eq!((s, e, p), (0, 999, false));
|
||
}
|
||
#[test]
|
||
fn range_open_ended() {
|
||
let h = HeaderValue::from_static("bytes=500-");
|
||
let (s, e, p) = parse_range(Some(&h), 1000).unwrap();
|
||
assert_eq!((s, e, p), (500, 999, true));
|
||
}
|
||
#[test]
|
||
fn range_suffix() {
|
||
let h = HeaderValue::from_static("bytes=-100");
|
||
let (s, e, p) = parse_range(Some(&h), 1000).unwrap();
|
||
assert_eq!((s, e, p), (900, 999, true));
|
||
}
|
||
#[test]
|
||
fn range_explicit() {
|
||
let h = HeaderValue::from_static("bytes=10-99");
|
||
let (s, e, p) = parse_range(Some(&h), 1000).unwrap();
|
||
assert_eq!((s, e, p), (10, 99, true));
|
||
}
|
||
|
||
#[test]
|
||
fn range_rejects_out_of_bounds_start() {
|
||
let h = HeaderValue::from_static("bytes=1000-");
|
||
let got = parse_range(Some(&h), 1000);
|
||
assert_eq!(got, None);
|
||
}
|
||
|
||
#[test]
|
||
fn range_rejects_start_after_end() {
|
||
let h = HeaderValue::from_static("bytes=99-10");
|
||
let got = parse_range(Some(&h), 1000);
|
||
assert_eq!(got, None);
|
||
}
|
||
}
|