Settings:
- New top-level Settings tab. Carries a placeholder for the planned
LDAP / OIDC / user-management work, the new branding controls, and
the API reference at the bottom.
- Custom logo upload (PNG/SVG/JPEG/WebP/GIF up to 2 MB) replaces the
bundled brand mark via /assets/logo.svg; bytes live at
<work_dir>/branding/ and survive restart. The original "OpenPXE
v<x.y.z>" pins to the sidebar footer for support.
- API reference rendered from a new GET /api/docs into a per-method
coloured pill list grouped by area.
ISO category (Storage):
- New IsoCategory { Os, Tools } on IsoMeta with PUT
/api/isos/:id/category. Storage table's Type cell becomes a
dropdown; selecting Tools moves the ISO into the Tools submenu next
to memtest / shell / NIC info and removes it from the OS Installers
family submenu. Family detection still drives BIOS/UEFI / kernel
args; only the menu placement changes.
Storage telemetry:
- New IsoStore::disk_usage (libc::statvfs, lives in iso-store so the
http-api crate stays #![forbid(unsafe_code)]) and GET
/api/storage/disk. The Storage tab now shows free/used/total for
the volume hosting the ISO directory with an 80%/95% colour ramp.
UI polish:
- Brand block in the sidebar now matches the topbar height exactly,
so the divider runs straight across the top of the app rather than
stepping; version label moved out of the brand and pinned to the
sidebar footer ("OpenPXE v0.4.4").
- Light-mode terminal: --terminal-bg + per-level text colours track
the active theme rather than being hard-coded dark.
- About: lead paragraph spans the full content width; new Docs row
links to https://openpxe.com/.
106 tests passing (was 89 in v0.4.1, +17 across branding unit tests
and new integration coverage for category / disk / docs / branding).
cargo clippy --workspace --all-targets clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1727 lines
69 KiB
Rust
1727 lines
69 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::ipxe_script::{
|
|
render_entry, render_family_menu, render_local_hdd, render_menu, render_nic_info,
|
|
render_queue_entry, render_shell, render_tools_menu, render_util,
|
|
};
|
|
use crate::iso_fs;
|
|
use crate::log_stream;
|
|
use crate::state::AppState;
|
|
use crate::terminal;
|
|
use axum::{
|
|
body::{Body, Bytes},
|
|
extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path as AxumPath, Query, State},
|
|
http::{header, HeaderMap, HeaderValue, StatusCode},
|
|
response::{IntoResponse, Response},
|
|
routing::{delete, get, post, put},
|
|
Json, Router,
|
|
};
|
|
use openpxe_core::{
|
|
ext_for_mime, BootEvent, ClientEvent, Error, Settings, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
|
|
};
|
|
use openpxe_ipxe_assets::asset_bytes;
|
|
use openpxe_iso_store::{IsoCategory, IsoMeta, NfsAddRequest};
|
|
use serde::Deserialize;
|
|
use serde_json::json;
|
|
use std::net::SocketAddr;
|
|
use std::time::Duration;
|
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
|
use tower_http::trace::TraceLayer;
|
|
|
|
pub fn build_router(state: AppState) -> Router {
|
|
Router::new()
|
|
// Web UI (fully offline — no CDN, no external fonts/images).
|
|
.route("/", get(index))
|
|
.route("/assets/app.js", get(ui_js))
|
|
.route("/assets/app.css", get(ui_css))
|
|
.route("/assets/logo.svg", get(ui_logo))
|
|
.route("/assets/loader.svg", get(ui_loader))
|
|
// iPXE script endpoints.
|
|
.route("/boot.ipxe", get(boot_top_menu))
|
|
.route("/boot/:filename", get(boot_sub))
|
|
// Bundled binaries and raw ISO access.
|
|
.route("/ipxe/:name", get(ipxe_binary))
|
|
.route("/iso/:filename", get(iso_raw))
|
|
.route("/iso/:id/*path", get(iso_file))
|
|
// Container health/readiness probes. `/healthz` is always 200 OK
|
|
// while the HTTP task is alive. `/readyz` additionally requires at
|
|
// least one bundled iPXE binary (without one, no client can PXE).
|
|
.route("/healthz", get(healthz))
|
|
.route("/readyz", get(readyz))
|
|
// JSON API.
|
|
.route("/api/isos", get(api_list_isos).post(api_upload_iso))
|
|
.route("/api/isos/:id", delete(api_delete_iso))
|
|
.route("/api/uploads", post(api_upload_begin))
|
|
.route(
|
|
"/api/uploads/:upload_id",
|
|
put(api_upload_chunk).delete(api_upload_abort),
|
|
)
|
|
// Per-ISO password prompt. PUT body `{ "password": "..." }`
|
|
// sets, `{ "password": null }` (or DELETE) clears.
|
|
.route(
|
|
"/api/isos/:id/password",
|
|
axum::routing::put(api_set_iso_password).delete(api_clear_iso_password),
|
|
)
|
|
// v0.4.4: per-ISO menu category (Os / Tools). Drives whether the
|
|
// image appears under Linux/Windows Installers (default) or in
|
|
// the Tools submenu next to memtest / shell / NIC info.
|
|
.route(
|
|
"/api/isos/:id/category",
|
|
axum::routing::put(api_set_iso_category),
|
|
)
|
|
// v0.4.4: filesystem free-space telemetry for the ISO directory's
|
|
// volume — surfaced as a small card on the Storage tab so the
|
|
// operator knows when they're about to run out of room.
|
|
.route("/api/storage/disk", get(api_storage_disk))
|
|
// v0.4.4: operator-controlled WebUI branding overrides
|
|
// (custom logo). Multipart upload to POST; DELETE clears.
|
|
.route(
|
|
"/api/branding/logo",
|
|
post(api_branding_upload).delete(api_branding_clear),
|
|
)
|
|
// v0.4.4: self-rendered API reference, served as JSON so the UI
|
|
// can format it consistently with the rest of the chrome. Lives
|
|
// under the Settings tab — operators chasing an integration get
|
|
// it in-product instead of having to fetch the OpenAPI YAML.
|
|
.route("/api/docs", get(api_docs))
|
|
.route("/api/clients", get(api_list_clients))
|
|
.route("/api/status", get(api_status))
|
|
.route("/api/settings", get(api_get_settings).put(api_put_settings))
|
|
.route("/api/queue", get(api_list_queue))
|
|
.route("/api/queue/join", get(api_queue_join))
|
|
.route("/api/queue/poll/:entry_id", get(api_queue_poll))
|
|
.route("/api/queue/assign", post(api_queue_assign))
|
|
.route("/api/queue/:entry_id", delete(api_queue_release))
|
|
// Phase 4: NFS share manager.
|
|
.route("/api/nfs", get(api_nfs_list).post(api_nfs_add))
|
|
.route("/api/nfs/:id", delete(api_nfs_remove))
|
|
.route("/api/nfs/:id/scan", post(api_nfs_scan))
|
|
// Phase 4: Network info (read-only) + DNS edit.
|
|
.route("/api/network", get(api_network).put(api_network_put))
|
|
// Phase 4: live-log stream + recent buffer for the Terminal tab.
|
|
.route("/api/log/stream", get(log_stream::stream))
|
|
.route("/api/log/recent", get(log_stream::recent))
|
|
.route("/api/log/clear", post(log_stream::clear))
|
|
// Phase 4: operator terminal commands (whitelisted).
|
|
.route("/api/terminal", post(terminal::run_command))
|
|
// Phase 5: per-MAC host bindings. Operator
|
|
// pins a MAC to a boot entry; /boot.ipxe?mac=... chains directly.
|
|
.route("/api/hosts", get(api_hosts_list).post(api_hosts_upsert))
|
|
.route("/api/hosts/:mac", delete(api_hosts_remove))
|
|
// Rolling "host log" of boot events: what image actually
|
|
// started installing on what MAC/IP, and when. Persisted to disk.
|
|
.route("/api/boot-log", get(api_boot_log))
|
|
// Phase 5: Prometheus scrape endpoint. Plain text exposition
|
|
// format. No auth — the metrics surface is intentionally
|
|
// boring (counts, no payloads).
|
|
.route("/metrics", get(api_metrics))
|
|
.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)
|
|
}
|
|
|
|
// ─── UI ────────────────────────────────────────────────────────────────────
|
|
|
|
async fn index(State(state): State<AppState>) -> Response {
|
|
let html = openpxe_webui::index_html(&state.public_base_url);
|
|
(
|
|
[(
|
|
header::CONTENT_TYPE,
|
|
HeaderValue::from_static("text/html; charset=utf-8"),
|
|
)],
|
|
html,
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
async fn ui_js() -> Response {
|
|
(
|
|
[(
|
|
header::CONTENT_TYPE,
|
|
HeaderValue::from_static("application/javascript"),
|
|
)],
|
|
openpxe_webui::app_js(),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
async fn ui_css() -> Response {
|
|
(
|
|
[(header::CONTENT_TYPE, HeaderValue::from_static("text/css"))],
|
|
openpxe_webui::app_css(),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
async fn ui_logo(State(state): State<AppState>) -> Response {
|
|
// Custom override first; fall back to the bundled rainbow-horizon
|
|
// SVG. We resolve the override on each request rather than caching
|
|
// because operators may upload/clear from the Settings tab while the
|
|
// server is live, and we want them to see their change immediately
|
|
// without bouncing the binary.
|
|
if let Some(path) = state.branding.logo_path() {
|
|
let mime = state
|
|
.branding
|
|
.logo_mime()
|
|
.unwrap_or_else(|| "image/svg+xml".to_string());
|
|
match tokio::fs::read(&path).await {
|
|
Ok(bytes) => {
|
|
let ct = match HeaderValue::from_str(&mime) {
|
|
Ok(v) => v,
|
|
Err(_) => HeaderValue::from_static("application/octet-stream"),
|
|
};
|
|
return (
|
|
[
|
|
(header::CONTENT_TYPE, ct),
|
|
(
|
|
header::CACHE_CONTROL,
|
|
HeaderValue::from_static("no-cache, max-age=0"),
|
|
),
|
|
],
|
|
bytes,
|
|
)
|
|
.into_response();
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
target: "openpxe::http::branding",
|
|
error = %e, "failed to read custom logo; falling back to bundled"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
(
|
|
[(
|
|
header::CONTENT_TYPE,
|
|
HeaderValue::from_static("image/svg+xml"),
|
|
)],
|
|
openpxe_webui::logo_svg(),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
async fn ui_loader() -> Response {
|
|
(
|
|
[(
|
|
header::CONTENT_TYPE,
|
|
HeaderValue::from_static("image/svg+xml"),
|
|
)],
|
|
openpxe_webui::loader_svg(),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
// ─── iPXE scripts ──────────────────────────────────────────────────────────
|
|
|
|
fn text_plain(body: String) -> Response {
|
|
(
|
|
[(
|
|
header::CONTENT_TYPE,
|
|
HeaderValue::from_static("text/plain; charset=utf-8"),
|
|
)],
|
|
body,
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
/// Top-level boot script. Honors per-MAC host bindings: if the
|
|
/// requesting client carries a `?mac=...` query param (iPXE's `${mac}`
|
|
/// substitution) and that MAC has a binding, we short-circuit straight
|
|
/// to the bound target instead of rendering the menu.
|
|
async fn boot_top_menu(
|
|
State(state): State<AppState>,
|
|
peer: Option<ConnectInfo<SocketAddr>>,
|
|
Query(p): Query<BootMenuParams>,
|
|
) -> Response {
|
|
// `ConnectInfo` is only populated when axum was started with
|
|
// `into_make_service_with_connect_info` (production path). Tests
|
|
// call the router via `oneshot`, which skips that wiring — we
|
|
// tolerate it by treating the peer as unknown rather than 500ing.
|
|
let peer_ip = peer.map(|c| c.0.ip());
|
|
state
|
|
.metrics
|
|
.record_http(openpxe_core::HttpRoute::BootScript);
|
|
let isos = state.iso_store.list();
|
|
let settings = state.settings.snapshot();
|
|
let base = &state.public_base_url;
|
|
|
|
// Per-MAC override: if the client identified itself and we have a
|
|
// binding, chain directly. The chain target falls back to the menu
|
|
// on failure so a stale / misconfigured binding can't lock a client
|
|
// out — it just shows the menu.
|
|
if let Some(mac) = p.mac.as_deref() {
|
|
if let Some(binding) = state.hosts.lookup(mac) {
|
|
tracing::info!(
|
|
target: "openpxe::http",
|
|
mac = %binding.mac, target = %binding.target,
|
|
"host binding applied"
|
|
);
|
|
// Pre-record the host-binding event. Reserved menu shortcuts
|
|
// (`_local`, `_queue`, …) are operator-driven non-imaging
|
|
// targets — recording them would clutter the Host log with
|
|
// routine console activity, so we skip those and only record
|
|
// for real boot-entry ids.
|
|
if !binding.target.starts_with('_') {
|
|
let title = lookup_entry_title(&isos, &binding.target);
|
|
state.boot_log.record(&BootEvent {
|
|
timestamp: time::OffsetDateTime::now_utc(),
|
|
mac: Some(binding.mac.clone()),
|
|
ip: peer_ip,
|
|
target_id: binding.target.clone(),
|
|
target_title: title,
|
|
});
|
|
}
|
|
let target = binding.target;
|
|
let bound_mac = binding.mac;
|
|
// Reserved menu shortcuts are emitted as `_xxx`; per-entry
|
|
// boot scripts are at `/boot/<id>.ipxe`. Both share the same
|
|
// `/boot/<name>` route, so the URL is identical. We forward
|
|
// `?mac=` so the per-entry handler can record the boot into
|
|
// the Host log without depending on iPXE substitution at
|
|
// this stage.
|
|
return text_plain(format!(
|
|
"#!ipxe\n\
|
|
echo OpenPXE: per-MAC binding -> {target}\n\
|
|
chain {base}/boot/{target}.ipxe?mac={bound_mac} || chain {base}/boot.ipxe\n"
|
|
));
|
|
}
|
|
}
|
|
|
|
text_plain(render_menu(&isos, &settings, base))
|
|
}
|
|
|
|
/// Best-effort human title for a boot entry id — falls back to the id
|
|
/// itself if the ISO has been deleted between record-time and now.
|
|
fn lookup_entry_title(isos: &[openpxe_iso_store::IsoMeta], target_id: &str) -> String {
|
|
for iso in isos {
|
|
for e in &iso.boot_entries {
|
|
if e.id == target_id {
|
|
// ISO filename plus the entry title gives the operator
|
|
// both "which image" and "which variant" (e.g. wimboot
|
|
// vs sanboot) at a glance.
|
|
return format!("{} — {}", iso.filename, e.title);
|
|
}
|
|
}
|
|
}
|
|
target_id.to_string()
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct BootMenuParams {
|
|
/// Client MAC, supplied by iPXE via `${mac}` variable in
|
|
/// `chain ${prefix}/boot.ipxe?mac=${mac}`. Optional — if absent we
|
|
/// fall back to the menu unconditionally.
|
|
mac: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct BootSubParams {
|
|
/// iPXE-supplied password token. Sent by the prompt script as
|
|
/// `?token=${password:uristring}` so special chars survive URL
|
|
/// encoding. Absent on the first request — that's how we know the
|
|
/// client hasn't been prompted yet.
|
|
token: Option<String>,
|
|
/// Client MAC, supplied by iPXE via `${mac}` in the chain URLs we
|
|
/// render. Optional — older bookmarks may omit it; the boot log
|
|
/// just records `None` in that case rather than refusing to boot.
|
|
mac: Option<String>,
|
|
}
|
|
|
|
async fn boot_sub(
|
|
State(state): State<AppState>,
|
|
peer: Option<ConnectInfo<SocketAddr>>,
|
|
AxumPath(filename): AxumPath<String>,
|
|
Query(p): Query<BootSubParams>,
|
|
) -> Response {
|
|
let peer_ip = peer.map(|c| c.0.ip());
|
|
// `/boot/<name>.ipxe` where `<name>` is either one of our reserved
|
|
// submenu names (prefixed `_`) or a boot entry id.
|
|
let name = filename.strip_suffix(".ipxe").unwrap_or(&filename);
|
|
let isos = state.iso_store.list();
|
|
let settings = state.settings.snapshot();
|
|
let base = &state.public_base_url;
|
|
let script = match name {
|
|
"_local" => render_local_hdd(base),
|
|
"_linux_menu" => render_family_menu(&isos, base, false),
|
|
"_windows_menu" => render_family_menu(&isos, base, true),
|
|
"_tools_menu" => render_tools_menu(&isos, base),
|
|
"_util" => render_util(base),
|
|
"_shell" => render_shell(base),
|
|
"_nic" => render_nic_info(base),
|
|
"_queue" => render_queue_entry(base),
|
|
other => {
|
|
for iso in &isos {
|
|
for entry in &iso.boot_entries {
|
|
if entry.id == other {
|
|
// Password prompt. If the ISO has a password set
|
|
// we block the actual boot script behind it:
|
|
// - no token -> render a prompt
|
|
// - wrong token -> render auth-fail
|
|
// - correct token -> serve the boot script
|
|
// ISO without a password ignores the token
|
|
// entirely, so per-MAC bookmarks stay simple.
|
|
if iso.is_password_protected() {
|
|
match p.token.as_deref() {
|
|
None | Some("") => {
|
|
return text_plain(crate::ipxe_script::render_password_prompt(
|
|
&entry.id,
|
|
&iso.filename,
|
|
base,
|
|
));
|
|
}
|
|
Some(token) => {
|
|
match state.iso_store.verify_password(&iso.id, token) {
|
|
Ok(true) => { /* fall through to render the entry */ }
|
|
Ok(false) => {
|
|
// Don't log the candidate — just the
|
|
// mac (when iPXE supplies one) and
|
|
// the entry id, so an operator can
|
|
// see brute-force attempts in the
|
|
// live log.
|
|
tracing::warn!(
|
|
target: "openpxe::http::boot",
|
|
entry = %other,
|
|
"wrong password supplied for protected boot entry"
|
|
);
|
|
return text_plain(
|
|
crate::ipxe_script::render_password_failed(
|
|
&entry.id, base,
|
|
),
|
|
);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(
|
|
target: "openpxe::http::boot",
|
|
entry = %other, error = %e,
|
|
"password verify failed unexpectedly"
|
|
);
|
|
return (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"password check failed",
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Record the boot event. This is the canonical
|
|
// moment: password gate (if any) passed, and the
|
|
// script is about to be served — i.e. the client
|
|
// is genuinely about to start imaging.
|
|
let mac_normalized = p
|
|
.mac
|
|
.as_deref()
|
|
.map(openpxe_core::normalize_mac)
|
|
.filter(|m| !m.is_empty());
|
|
state.boot_log.record(&BootEvent {
|
|
timestamp: time::OffsetDateTime::now_utc(),
|
|
mac: mac_normalized,
|
|
ip: peer_ip,
|
|
target_id: entry.id.clone(),
|
|
target_title: format!("{} — {}", iso.filename, entry.title),
|
|
});
|
|
return text_plain(render_entry(entry, &settings, base));
|
|
}
|
|
}
|
|
}
|
|
return (StatusCode::NOT_FOUND, "no such boot entry").into_response();
|
|
}
|
|
};
|
|
text_plain(script)
|
|
}
|
|
|
|
// ─── bundled iPXE binaries (memtest lives here too) ───────────────────────
|
|
|
|
async fn ipxe_binary(AxumPath(name): AxumPath<String>) -> Response {
|
|
if name.contains('/') || name.contains('\\') {
|
|
return (StatusCode::BAD_REQUEST, "invalid name").into_response();
|
|
}
|
|
let Some(bytes) = asset_bytes(&name) else {
|
|
return (StatusCode::NOT_FOUND, "no such ipxe asset").into_response();
|
|
};
|
|
(
|
|
[
|
|
(
|
|
header::CONTENT_TYPE,
|
|
HeaderValue::from_static("application/octet-stream"),
|
|
),
|
|
(header::CONTENT_LENGTH, HeaderValue::from(bytes.len())),
|
|
],
|
|
bytes,
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
// ─── ISO streaming (raw + in-ISO) ─────────────────────────────────────────
|
|
|
|
async fn iso_raw(
|
|
State(state): State<AppState>,
|
|
AxumPath(filename): AxumPath<String>,
|
|
headers: HeaderMap,
|
|
) -> Response {
|
|
let id = filename.strip_suffix(".iso").unwrap_or(&filename);
|
|
let Some(path) = state.iso_store.iso_path_for(id) else {
|
|
return (StatusCode::NOT_FOUND, "no such iso").into_response();
|
|
};
|
|
match stream_file_range(&path, headers.get(header::RANGE)).await {
|
|
Ok(r) => r,
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn iso_file(
|
|
State(state): State<AppState>,
|
|
AxumPath((id, path)): AxumPath<(String, String)>,
|
|
) -> Response {
|
|
let Some(iso_path) = state.iso_store.iso_path_for(&id) else {
|
|
return (StatusCode::NOT_FOUND, "no such iso").into_response();
|
|
};
|
|
let p = iso_path.clone();
|
|
let in_path = format!("/{path}");
|
|
let loc = tokio::task::spawn_blocking(move || iso_fs::lookup(&p, &in_path))
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
let Some(loc) = loc else {
|
|
return (StatusCode::NOT_FOUND, "not found inside iso").into_response();
|
|
};
|
|
match stream_byte_range(&iso_path, loc.offset, loc.length).await {
|
|
Ok(r) => r,
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn stream_file_range(
|
|
path: &std::path::Path,
|
|
range: Option<&HeaderValue>,
|
|
) -> anyhow::Result<Response> {
|
|
let meta = tokio::fs::metadata(path).await?;
|
|
let total = meta.len();
|
|
if total == 0 {
|
|
return Ok(Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(header::CONTENT_TYPE, "application/octet-stream")
|
|
.header(header::ACCEPT_RANGES, "bytes")
|
|
.header(header::CONTENT_LENGTH, 0)
|
|
.body(Body::empty())
|
|
.unwrap());
|
|
}
|
|
let Some((start, end, partial)) = parse_range(range, total) else {
|
|
return Ok(Response::builder()
|
|
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
|
.header(header::CONTENT_RANGE, format!("bytes */{total}"))
|
|
.body(Body::empty())
|
|
.unwrap());
|
|
};
|
|
let len = end - start + 1;
|
|
let mut file = tokio::fs::File::open(path).await?;
|
|
file.seek(std::io::SeekFrom::Start(start)).await?;
|
|
let reader = file.take(len);
|
|
let stream = tokio_util::io::ReaderStream::new(reader);
|
|
let body = Body::from_stream(stream);
|
|
let status = if partial {
|
|
StatusCode::PARTIAL_CONTENT
|
|
} else {
|
|
StatusCode::OK
|
|
};
|
|
let mut builder = Response::builder()
|
|
.status(status)
|
|
.header(header::CONTENT_TYPE, "application/octet-stream")
|
|
.header(header::ACCEPT_RANGES, "bytes")
|
|
.header(header::CONTENT_LENGTH, len);
|
|
if partial {
|
|
builder = builder.header(
|
|
header::CONTENT_RANGE,
|
|
format!("bytes {start}-{end}/{total}"),
|
|
);
|
|
}
|
|
Ok(builder.body(body).unwrap())
|
|
}
|
|
|
|
async fn stream_byte_range(
|
|
path: &std::path::Path,
|
|
offset: u64,
|
|
length: u64,
|
|
) -> anyhow::Result<Response> {
|
|
let mut file = tokio::fs::File::open(path).await?;
|
|
file.seek(std::io::SeekFrom::Start(offset)).await?;
|
|
let reader = file.take(length);
|
|
let stream = tokio_util::io::ReaderStream::new(reader);
|
|
let body = Body::from_stream(stream);
|
|
Ok(Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(header::CONTENT_TYPE, "application/octet-stream")
|
|
.header(header::CONTENT_LENGTH, length)
|
|
.body(body)
|
|
.unwrap())
|
|
}
|
|
|
|
fn parse_range(h: Option<&HeaderValue>, total: u64) -> Option<(u64, u64, bool)> {
|
|
let Some(h) = h else {
|
|
return Some((0, total.saturating_sub(1), false));
|
|
};
|
|
let Ok(s) = h.to_str() else {
|
|
return Some((0, total.saturating_sub(1), false));
|
|
};
|
|
let Some(spec) = s.strip_prefix("bytes=") else {
|
|
return Some((0, total.saturating_sub(1), false));
|
|
};
|
|
let spec = spec.split(',').next().unwrap_or("").trim();
|
|
if let Some(suffix) = spec.strip_prefix('-') {
|
|
if let Ok(n) = suffix.parse::<u64>() {
|
|
let n = n.min(total);
|
|
return Some((total.saturating_sub(n), total.saturating_sub(1), true));
|
|
}
|
|
}
|
|
let mut parts = spec.splitn(2, '-');
|
|
let start = parts
|
|
.next()
|
|
.and_then(|s| s.parse::<u64>().ok())
|
|
.unwrap_or(0);
|
|
let end = parts
|
|
.next()
|
|
.and_then(|s| s.parse::<u64>().ok())
|
|
.unwrap_or(total.saturating_sub(1));
|
|
if start >= total {
|
|
return None;
|
|
}
|
|
let end = end.min(total.saturating_sub(1));
|
|
if start > end {
|
|
return None;
|
|
}
|
|
Some((start, end, true))
|
|
}
|
|
|
|
// ─── ISO upload / list / delete ───────────────────────────────────────────
|
|
|
|
async fn api_list_isos(State(state): State<AppState>) -> Json<Vec<IsoMeta>> {
|
|
Json(state.iso_store.list())
|
|
}
|
|
|
|
async fn api_delete_iso(
|
|
State(state): State<AppState>,
|
|
AxumPath(id): AxumPath<String>,
|
|
) -> StatusCode {
|
|
match state.iso_store.delete(&id).await {
|
|
Ok(()) => StatusCode::NO_CONTENT,
|
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct SetPasswordBody {
|
|
/// Plaintext password. `null` or empty/whitespace clears the
|
|
/// password (same as a DELETE on this resource). The server hashes
|
|
/// with bcrypt before persisting; the plaintext is never stored.
|
|
password: Option<String>,
|
|
}
|
|
|
|
async fn api_set_iso_password(
|
|
State(state): State<AppState>,
|
|
AxumPath(id): AxumPath<String>,
|
|
Json(body): Json<SetPasswordBody>,
|
|
) -> Response {
|
|
match state
|
|
.iso_store
|
|
.set_password(&id, body.password.as_deref())
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
let now_protected = state
|
|
.iso_store
|
|
.get(&id)
|
|
.is_some_and(|m| m.is_password_protected());
|
|
// We deliberately do not log the password value, only
|
|
// whether the ISO ended up protected.
|
|
tracing::info!(
|
|
target: "openpxe::http::iso",
|
|
iso = %id, protected = now_protected,
|
|
"iso password updated"
|
|
);
|
|
StatusCode::NO_CONTENT.into_response()
|
|
}
|
|
Err(openpxe_error_invalid)
|
|
if matches!(openpxe_error_invalid, openpxe_core::Error::Invalid(_)) =>
|
|
{
|
|
(StatusCode::NOT_FOUND, format!("{openpxe_error_invalid}")).into_response()
|
|
}
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn api_clear_iso_password(
|
|
State(state): State<AppState>,
|
|
AxumPath(id): AxumPath<String>,
|
|
) -> Response {
|
|
match state.iso_store.set_password(&id, None).await {
|
|
Ok(()) => {
|
|
tracing::info!(
|
|
target: "openpxe::http::iso",
|
|
iso = %id, "iso password cleared"
|
|
);
|
|
StatusCode::NO_CONTENT.into_response()
|
|
}
|
|
Err(e) => (StatusCode::NOT_FOUND, format!("{e}")).into_response(),
|
|
}
|
|
}
|
|
|
|
// ─── ISO category (OS / Tools) ────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct SetCategoryBody {
|
|
/// `"os"` or `"tools"` — matches `IsoCategory`'s snake_case serde
|
|
/// repr. Anything else returns 400 with the allowed set spelled out.
|
|
category: String,
|
|
}
|
|
|
|
async fn api_set_iso_category(
|
|
State(state): State<AppState>,
|
|
AxumPath(id): AxumPath<String>,
|
|
Json(body): Json<SetCategoryBody>,
|
|
) -> Response {
|
|
let cat = match body.category.as_str() {
|
|
"os" => IsoCategory::Os,
|
|
"tools" => IsoCategory::Tools,
|
|
other => {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
format!("unknown category '{other}'; expected one of: os, tools"),
|
|
)
|
|
.into_response();
|
|
}
|
|
};
|
|
match state.iso_store.set_category(&id, cat).await {
|
|
Ok(meta) => {
|
|
tracing::info!(
|
|
target: "openpxe::http::iso",
|
|
iso = %id, category = ?cat,
|
|
"iso category updated"
|
|
);
|
|
(StatusCode::OK, Json(meta)).into_response()
|
|
}
|
|
Err(Error::Invalid(msg)) => (StatusCode::NOT_FOUND, msg).into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
|
}
|
|
}
|
|
|
|
// ─── Disk space (Storage tab) ─────────────────────────────────────────────
|
|
|
|
async fn api_storage_disk(State(state): State<AppState>) -> Json<serde_json::Value> {
|
|
// statvfs on the directory that holds the ISO store. We deliberately
|
|
// don't walk the directory ourselves — the kernel already tracks
|
|
// free/total at the volume level and that's the only number the
|
|
// operator actually cares about for "do I have room for one more
|
|
// 5 GB ISO?". `statvfs` itself lives in iso-store to keep the
|
|
// http-api crate free of `unsafe`.
|
|
let dir = state.iso_store.iso_dir();
|
|
let (total, available) = state.iso_store.disk_usage().unwrap_or((0, 0));
|
|
let used = total.saturating_sub(available);
|
|
Json(json!({
|
|
"path": dir.to_string_lossy(),
|
|
"total_bytes": total,
|
|
"available_bytes": available,
|
|
"used_bytes": used,
|
|
}))
|
|
}
|
|
|
|
// ─── Branding (custom logo) ───────────────────────────────────────────────
|
|
|
|
async fn api_branding_upload(
|
|
State(state): State<AppState>,
|
|
mut multipart: Multipart,
|
|
) -> Response {
|
|
while let Ok(Some(field)) = multipart.next_field().await {
|
|
let name = field.name().unwrap_or("").to_string();
|
|
if name != "file" && name != "logo" {
|
|
continue;
|
|
}
|
|
let mime = field.content_type().unwrap_or("").to_string();
|
|
if !ALLOWED_LOGO_MIMES.iter().any(|m| *m == mime) {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
format!(
|
|
"unsupported MIME '{mime}'. Allowed: {}",
|
|
ALLOWED_LOGO_MIMES.join(", ")
|
|
),
|
|
)
|
|
.into_response();
|
|
}
|
|
// Pre-read into memory so we can enforce the size cap before
|
|
// hitting disk. Logos are tiny by definition.
|
|
let bytes = match field.bytes().await {
|
|
Ok(b) => b,
|
|
Err(e) => {
|
|
return (StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response()
|
|
}
|
|
};
|
|
if bytes.len() > MAX_LOGO_BYTES {
|
|
return (
|
|
StatusCode::PAYLOAD_TOO_LARGE,
|
|
format!(
|
|
"logo too large ({} bytes, max {})",
|
|
bytes.len(),
|
|
MAX_LOGO_BYTES
|
|
),
|
|
)
|
|
.into_response();
|
|
}
|
|
let Some(ext) = ext_for_mime(&mime) else {
|
|
return (StatusCode::BAD_REQUEST, "unsupported MIME").into_response();
|
|
};
|
|
match state.branding.set_logo(&mime, ext, &bytes) {
|
|
Ok(filename) => {
|
|
return (
|
|
StatusCode::OK,
|
|
Json(json!({
|
|
"filename": filename,
|
|
"mime": mime,
|
|
"size_bytes": bytes.len(),
|
|
})),
|
|
)
|
|
.into_response()
|
|
}
|
|
Err(e) => {
|
|
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response()
|
|
}
|
|
}
|
|
}
|
|
(StatusCode::BAD_REQUEST, "no 'file' part").into_response()
|
|
}
|
|
|
|
async fn api_branding_clear(State(state): State<AppState>) -> Response {
|
|
match state.branding.clear_logo() {
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
|
}
|
|
}
|
|
|
|
// ─── API reference (Settings → bottom) ────────────────────────────────────
|
|
|
|
async fn api_docs() -> Json<serde_json::Value> {
|
|
// Hand-curated rather than introspected from axum because:
|
|
// 1. axum's runtime route table doesn't carry parameter docs;
|
|
// 2. the WebUI surfaces this as a readable list, not as an OpenAPI
|
|
// spec — readers are operators chasing an integration, not
|
|
// machines.
|
|
// Keep this in lockstep with `build_router` when adding endpoints.
|
|
Json(json!({
|
|
"version": env!("CARGO_PKG_VERSION"),
|
|
"groups": [
|
|
{
|
|
"name": "Status & health",
|
|
"endpoints": [
|
|
{"method": "GET", "path": "/healthz",
|
|
"summary": "Liveness — always 200 OK while the HTTP task is alive."},
|
|
{"method": "GET", "path": "/readyz",
|
|
"summary": "Readiness — 200 only when iPXE binaries are bundled and the ISO directory is readable."},
|
|
{"method": "GET", "path": "/api/status",
|
|
"summary": "Dashboard JSON — versions, counts, settings snapshot, uptime."},
|
|
{"method": "GET", "path": "/metrics",
|
|
"summary": "Prometheus text exposition (counters + gauges)."},
|
|
],
|
|
},
|
|
{
|
|
"name": "ISO images",
|
|
"endpoints": [
|
|
{"method": "GET", "path": "/api/isos",
|
|
"summary": "List ISOs (local + NFS) with size, family, boot entries, category."},
|
|
{"method": "POST", "path": "/api/isos",
|
|
"summary": "Legacy single-shot multipart upload. Prefer /api/uploads for big files."},
|
|
{"method": "DELETE", "path": "/api/isos/:id",
|
|
"summary": "Delete a local ISO and its sidecar metadata."},
|
|
{"method": "PUT", "path": "/api/isos/:id/password",
|
|
"summary": "Set or update an ISO's boot password (bcrypt-hashed; plaintext never stored)."},
|
|
{"method": "DELETE", "path": "/api/isos/:id/password",
|
|
"summary": "Clear an ISO's boot password."},
|
|
{"method": "PUT", "path": "/api/isos/:id/category",
|
|
"summary": "Set the menu category. Body: { \"category\": \"os\" | \"tools\" }."},
|
|
],
|
|
},
|
|
{
|
|
"name": "Chunked uploads",
|
|
"endpoints": [
|
|
{"method": "POST", "path": "/api/uploads",
|
|
"summary": "Begin a chunked upload session. Body: { \"filename\", \"size_bytes\" }."},
|
|
{"method": "PUT", "path": "/api/uploads/:upload_id",
|
|
"summary": "Append a chunk. Headers: x-openpxe-upload-offset, x-openpxe-upload-complete."},
|
|
{"method": "DELETE", "path": "/api/uploads/:upload_id",
|
|
"summary": "Abort a chunked upload session and remove the .partial file."},
|
|
],
|
|
},
|
|
{
|
|
"name": "NFS shares",
|
|
"endpoints": [
|
|
{"method": "GET", "path": "/api/nfs",
|
|
"summary": "List configured NFS shares with mount state and iso counts."},
|
|
{"method": "POST", "path": "/api/nfs",
|
|
"summary": "Mount an NFS share. Body: { server, export, version, read_only }."},
|
|
{"method": "DELETE", "path": "/api/nfs/:id",
|
|
"summary": "Unmount a share and drop its entries from the ISO store."},
|
|
{"method": "POST", "path": "/api/nfs/:id/scan",
|
|
"summary": "Re-walk a mounted share for ISOs."},
|
|
],
|
|
},
|
|
{
|
|
"name": "Network",
|
|
"endpoints": [
|
|
{"method": "GET", "path": "/api/network",
|
|
"summary": "Detected NIC, server IP, subnet, gateway, advertised base URL."},
|
|
{"method": "PUT", "path": "/api/network",
|
|
"summary": "Update the informational DNS server hint (does not run DNS)."},
|
|
],
|
|
},
|
|
{
|
|
"name": "Settings",
|
|
"endpoints": [
|
|
{"method": "GET", "path": "/api/settings",
|
|
"summary": "Current runtime settings (Windows toggle, timeout, dns hint, …)."},
|
|
{"method": "PUT", "path": "/api/settings",
|
|
"summary": "Replace runtime settings. Guards against enabling Windows when wimboot isn't bundled."},
|
|
{"method": "POST", "path": "/api/branding/logo",
|
|
"summary": "Upload a custom WebUI logo (multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB)."},
|
|
{"method": "DELETE", "path": "/api/branding/logo",
|
|
"summary": "Remove the custom logo and revert to the bundled mark."},
|
|
{"method": "GET", "path": "/api/docs",
|
|
"summary": "This API reference."},
|
|
],
|
|
},
|
|
{
|
|
"name": "Storage telemetry",
|
|
"endpoints": [
|
|
{"method": "GET", "path": "/api/storage/disk",
|
|
"summary": "Free / used / total bytes for the volume hosting the ISO directory."},
|
|
],
|
|
},
|
|
{
|
|
"name": "Queued Deployment",
|
|
"endpoints": [
|
|
{"method": "GET", "path": "/api/queue",
|
|
"summary": "List queue entries (waiting + assigned)."},
|
|
{"method": "POST", "path": "/api/queue/assign",
|
|
"summary": "Assign a target image to queued clients. Body: { target, entry_ids }."},
|
|
{"method": "DELETE", "path": "/api/queue/:entry_id",
|
|
"summary": "Release a queue entry without assigning."},
|
|
],
|
|
},
|
|
{
|
|
"name": "Hosts & boot log",
|
|
"endpoints": [
|
|
{"method": "GET", "path": "/api/hosts",
|
|
"summary": "List per-MAC boot bindings."},
|
|
{"method": "POST", "path": "/api/hosts",
|
|
"summary": "Pin a MAC to a boot target. Body: { mac, target, label }."},
|
|
{"method": "DELETE", "path": "/api/hosts/:mac",
|
|
"summary": "Remove a binding."},
|
|
{"method": "GET", "path": "/api/boot-log",
|
|
"summary": "Ring of recent boot events (timestamp, mac, ip, target)."},
|
|
],
|
|
},
|
|
{
|
|
"name": "Operator console",
|
|
"endpoints": [
|
|
{"method": "GET", "path": "/api/clients",
|
|
"summary": "Live PXE-client registry — MAC, last IP, arch, events."},
|
|
{"method": "GET", "path": "/api/log/recent",
|
|
"summary": "Ring of recent server log lines for the Terminal tab."},
|
|
{"method": "GET", "path": "/api/log/stream",
|
|
"summary": "Server-Sent Events stream of log lines."},
|
|
{"method": "POST", "path": "/api/terminal",
|
|
"summary": "Run a whitelisted operator command. Body: { command }."},
|
|
],
|
|
},
|
|
],
|
|
}))
|
|
}
|
|
|
|
async fn api_upload_iso(State(state): State<AppState>, mut multipart: Multipart) -> Response {
|
|
// Walk multipart parts until we find the file. Each branch logs so an
|
|
// operator chasing a "stuck" upload in the Terminal tab can see
|
|
// exactly which stage failed (no field, wrong field name, parser
|
|
// error, mid-stream drop, sha mismatch on finish, etc.).
|
|
loop {
|
|
let field_res = multipart.next_field().await;
|
|
match field_res {
|
|
Ok(Some(mut field)) => {
|
|
if field.name() != Some("file") {
|
|
tracing::debug!(
|
|
target: "openpxe::http::upload",
|
|
field = field.name().unwrap_or("?"),
|
|
"skipping non-file multipart part"
|
|
);
|
|
continue;
|
|
}
|
|
let filename = field.file_name().unwrap_or("uploaded.iso").to_string();
|
|
if !filename.to_ascii_lowercase().ends_with(".iso") {
|
|
tracing::warn!(
|
|
target: "openpxe::http::upload",
|
|
filename = %filename, "rejecting non-.iso upload"
|
|
);
|
|
return (StatusCode::BAD_REQUEST, "only .iso uploads accepted").into_response();
|
|
}
|
|
tracing::info!(
|
|
target: "openpxe::http::upload",
|
|
filename = %filename, "upload started"
|
|
);
|
|
let mut handle = match state.iso_store.begin_upload(&filename).await {
|
|
Ok(h) => h,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
target: "openpxe::http::upload",
|
|
filename = %filename, error = %e,
|
|
"begin_upload rejected (likely duplicate name)"
|
|
);
|
|
return (StatusCode::CONFLICT, format!("{e}")).into_response();
|
|
}
|
|
};
|
|
// Streamed reader loop. We use an explicit `match` instead
|
|
// of `while let Ok(Some(_))` so a mid-stream `Err(_)` (a
|
|
// truncated body from a reverse proxy 524 / network drop)
|
|
// is treated as a failure rather than silently completing
|
|
// with a partial file.
|
|
let mut bytes: u64 = 0;
|
|
let mut next_log_at: u64 = 64 * 1024 * 1024;
|
|
loop {
|
|
match field.chunk().await {
|
|
Ok(Some(chunk)) => {
|
|
if let Err(e) = handle.write_chunk(&chunk).await {
|
|
tracing::error!(
|
|
target: "openpxe::http::upload",
|
|
filename = %filename, bytes,
|
|
error = %e, "write_chunk failed; aborting"
|
|
);
|
|
let _ = handle.abort().await;
|
|
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}"))
|
|
.into_response();
|
|
}
|
|
bytes += chunk.len() as u64;
|
|
if bytes >= next_log_at {
|
|
tracing::info!(
|
|
target: "openpxe::http::upload",
|
|
filename = %filename,
|
|
received_bytes = bytes,
|
|
"upload streaming"
|
|
);
|
|
// Backoff log cadence: 64 MB, 128, 256, …
|
|
next_log_at = next_log_at.saturating_mul(2);
|
|
}
|
|
}
|
|
Ok(None) => break,
|
|
Err(e) => {
|
|
tracing::error!(
|
|
target: "openpxe::http::upload",
|
|
filename = %filename, received_bytes = bytes,
|
|
error = %e,
|
|
"multipart stream ended with error (likely client \
|
|
disconnect or reverse-proxy buffer cap); aborting"
|
|
);
|
|
let _ = handle.abort().await;
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
format!(
|
|
"upload truncated after {bytes} bytes: {e}. \
|
|
If you went through a reverse proxy, try the \
|
|
LAN IP directly — large body buffering caps \
|
|
(Cloudflare free tier is 100 MB) commonly \
|
|
cause this."
|
|
),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
}
|
|
tracing::info!(
|
|
target: "openpxe::http::upload",
|
|
filename = %filename, received_bytes = bytes,
|
|
"upload body complete; introspecting"
|
|
);
|
|
let meta = match handle.finish(&state.iso_store).await {
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
tracing::error!(
|
|
target: "openpxe::http::upload",
|
|
filename = %filename, error = %e,
|
|
"finish failed (rename/introspect)"
|
|
);
|
|
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response();
|
|
}
|
|
};
|
|
tracing::info!(
|
|
target: "openpxe::http::upload",
|
|
iso = %meta.id, size = meta.size_bytes,
|
|
family = ?meta.introspection.family,
|
|
entries = meta.boot_entries.len(),
|
|
"upload finished"
|
|
);
|
|
return (StatusCode::CREATED, Json(meta)).into_response();
|
|
}
|
|
Ok(None) => {
|
|
tracing::warn!(target: "openpxe::http::upload", "upload had no 'file' part");
|
|
return (StatusCode::BAD_REQUEST, "no 'file' part").into_response();
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(
|
|
target: "openpxe::http::upload",
|
|
error = %e,
|
|
"multipart parser error before reading any field"
|
|
);
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
format!("multipart parse error: {e}"),
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct UploadBeginBody {
|
|
filename: String,
|
|
#[serde(default)]
|
|
size_bytes: Option<u64>,
|
|
}
|
|
|
|
async fn api_upload_begin(
|
|
State(state): State<AppState>,
|
|
Json(body): Json<UploadBeginBody>,
|
|
) -> Response {
|
|
match state
|
|
.uploads
|
|
.begin(&state.iso_store, &body.filename, body.size_bytes)
|
|
.await
|
|
{
|
|
Ok(started) => {
|
|
tracing::info!(
|
|
target: "openpxe::http::upload",
|
|
upload_id = %started.upload_id,
|
|
iso = %started.iso_id,
|
|
filename = %started.filename,
|
|
expected_size = ?body.size_bytes,
|
|
"chunked upload started"
|
|
);
|
|
(StatusCode::CREATED, Json(started)).into_response()
|
|
}
|
|
Err(Error::Invalid(e)) if e.contains("already exists") => {
|
|
(StatusCode::CONFLICT, e).into_response()
|
|
}
|
|
Err(Error::Invalid(e)) => (StatusCode::BAD_REQUEST, e).into_response(),
|
|
Err(e) => {
|
|
tracing::error!(target: "openpxe::http::upload", error = %e, "chunked upload begin failed");
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn api_upload_chunk(
|
|
State(state): State<AppState>,
|
|
AxumPath(upload_id): AxumPath<String>,
|
|
headers: HeaderMap,
|
|
chunk: Bytes,
|
|
) -> Response {
|
|
let Some(offset) = parse_u64_header(&headers, "x-openpxe-upload-offset") else {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
"missing or invalid x-openpxe-upload-offset",
|
|
)
|
|
.into_response();
|
|
};
|
|
let complete = bool_header(&headers, "x-openpxe-upload-complete");
|
|
|
|
match state
|
|
.uploads
|
|
.append(&state.iso_store, &upload_id, offset, chunk, complete)
|
|
.await
|
|
{
|
|
Ok(crate::uploads::UploadAppend::Progress { offset }) => (
|
|
StatusCode::ACCEPTED,
|
|
Json(json!({
|
|
"ok": true,
|
|
"upload_id": upload_id,
|
|
"offset": offset,
|
|
"complete": false,
|
|
})),
|
|
)
|
|
.into_response(),
|
|
Ok(crate::uploads::UploadAppend::Complete { offset, iso }) => {
|
|
tracing::info!(
|
|
target: "openpxe::http::upload",
|
|
upload_id = %upload_id,
|
|
iso = %iso.id,
|
|
size = iso.size_bytes,
|
|
family = ?iso.introspection.family,
|
|
entries = iso.boot_entries.len(),
|
|
"chunked upload finished"
|
|
);
|
|
(
|
|
StatusCode::CREATED,
|
|
Json(json!({
|
|
"ok": true,
|
|
"upload_id": upload_id,
|
|
"offset": offset,
|
|
"complete": true,
|
|
"iso": iso,
|
|
})),
|
|
)
|
|
.into_response()
|
|
}
|
|
Err(Error::Invalid(e)) if e.starts_with("expected offset") => {
|
|
(StatusCode::CONFLICT, e).into_response()
|
|
}
|
|
Err(Error::Invalid(e)) if e.starts_with("no such upload") => {
|
|
(StatusCode::NOT_FOUND, e).into_response()
|
|
}
|
|
Err(Error::Invalid(e)) => (StatusCode::BAD_REQUEST, e).into_response(),
|
|
Err(e) => {
|
|
tracing::error!(
|
|
target: "openpxe::http::upload",
|
|
upload_id = %upload_id,
|
|
error = %e,
|
|
"chunked upload failed"
|
|
);
|
|
(StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn api_upload_abort(
|
|
State(state): State<AppState>,
|
|
AxumPath(upload_id): AxumPath<String>,
|
|
) -> Response {
|
|
match state.uploads.abort(&upload_id).await {
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(Error::Invalid(e)) if e.starts_with("no such upload") => {
|
|
(StatusCode::NOT_FOUND, e).into_response()
|
|
}
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
|
}
|
|
}
|
|
|
|
fn parse_u64_header(headers: &HeaderMap, name: &'static str) -> Option<u64> {
|
|
headers.get(name)?.to_str().ok()?.trim().parse::<u64>().ok()
|
|
}
|
|
|
|
fn bool_header(headers: &HeaderMap, name: &'static str) -> bool {
|
|
headers
|
|
.get(name)
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(str::trim)
|
|
.is_some_and(|v| matches!(v, "1" | "true" | "TRUE" | "yes" | "YES"))
|
|
}
|
|
|
|
// ─── health / readiness ───────────────────────────────────────────────────
|
|
|
|
async fn healthz() -> Response {
|
|
// Simple liveness — HTTP task is responsive. Does not touch storage or
|
|
// other subsystems so we never fail for downstream reasons.
|
|
(
|
|
[(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))],
|
|
"ok\n",
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
async fn readyz(State(state): State<AppState>) -> Response {
|
|
// Readiness — can we actually serve clients?
|
|
// 1. At least one iPXE binary must be bundled (without one, TFTP 404s
|
|
// and no client boots).
|
|
// 2. The ISO directory must exist and be readable.
|
|
let assets = openpxe_ipxe_assets::list_assets();
|
|
let mut problems: Vec<&str> = Vec::new();
|
|
if assets.is_empty() {
|
|
problems.push("no iPXE binaries bundled (run scripts/fetch-ipxe.sh before building)");
|
|
}
|
|
let iso_dir_ok = state.iso_store.list_ok();
|
|
if !iso_dir_ok {
|
|
problems.push("iso directory not readable");
|
|
}
|
|
if problems.is_empty() {
|
|
(
|
|
[(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))],
|
|
"ready\n",
|
|
)
|
|
.into_response()
|
|
} else {
|
|
let body = format!("not ready:\n- {}\n", problems.join("\n- "));
|
|
(StatusCode::SERVICE_UNAVAILABLE, body).into_response()
|
|
}
|
|
}
|
|
|
|
// ─── clients + status + settings ──────────────────────────────────────────
|
|
|
|
async fn api_list_clients(State(state): State<AppState>) -> Json<serde_json::Value> {
|
|
Json(json!({ "clients": state.clients.list() }))
|
|
}
|
|
|
|
async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
|
|
let smb = state.smb.as_ref().map(|s| s.snapshot());
|
|
let nfs = state.nfs.list();
|
|
let nfs_active = nfs.iter().filter(|m| m.mounted).count();
|
|
let isos = state.iso_store.list();
|
|
let clients = state.clients.list();
|
|
let queue_entries = state.queue.list();
|
|
// Phase 4: dashboard tracks "imaging" as queue entries with an assignment
|
|
// already issued — they're the ones actively chaining a boot script.
|
|
let imaging = queue_entries
|
|
.iter()
|
|
.filter(|entry| entry.assigned_target.is_some())
|
|
.count();
|
|
let waiting = queue_entries.len() - imaging;
|
|
// Side-effect: push gauge values out to the Prometheus surface.
|
|
// Doing it here (in the most-frequently-polled endpoint) keeps the
|
|
// gauges fresh without a dedicated scrape-time hook.
|
|
state.metrics.set_iso_count(isos.len() as u64);
|
|
state.metrics.set_client_count(clients.len() as u64);
|
|
state
|
|
.metrics
|
|
.set_queue_counts(queue_entries.len() as u64, imaging as u64);
|
|
state.metrics.set_nfs_active(nfs_active as u64);
|
|
state.metrics.record_http(openpxe_core::HttpRoute::Api);
|
|
let now = time::OffsetDateTime::now_utc();
|
|
let uptime_secs = (now - state.started_at).whole_seconds().max(0);
|
|
Json(json!({
|
|
"version": env!("CARGO_PKG_VERSION"),
|
|
"public_base_url": state.public_base_url,
|
|
"iso_count": isos.len(),
|
|
"client_count": clients.len(),
|
|
"queue_count": queue_entries.len(),
|
|
"imaging_count": imaging,
|
|
"waiting_count": waiting,
|
|
"ipxe_assets": openpxe_ipxe_assets::list_assets(),
|
|
"settings": state.settings.snapshot(),
|
|
"smb": smb,
|
|
"nfs_count": nfs.len(),
|
|
"nfs_active": nfs_active,
|
|
"host_bindings": state.hosts.len(),
|
|
"custom_logo": state.branding.has_logo(),
|
|
"uptime_secs": uptime_secs,
|
|
"started_at": state.started_at,
|
|
"nic_name": state.nic_name,
|
|
"subnet_mask": state.subnet_mask,
|
|
"gateway": state.gateway,
|
|
}))
|
|
}
|
|
|
|
async fn api_get_settings(State(state): State<AppState>) -> Json<Settings> {
|
|
Json(state.settings.snapshot())
|
|
}
|
|
|
|
async fn api_put_settings(
|
|
State(state): State<AppState>,
|
|
Json(mut new): Json<Settings>,
|
|
) -> Response {
|
|
// Guardrail: Windows boot requires the wimboot shim to be bundled.
|
|
// Without it, clients chain a non-existent /ipxe/wimboot and stall.
|
|
if new.windows_enabled {
|
|
let assets = openpxe_ipxe_assets::list_assets();
|
|
if !assets.iter().any(|n| n == "wimboot") {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
"cannot enable Windows: 'wimboot' binary is not bundled. \
|
|
Place a signed wimboot build at assets/ipxe/wimboot and rebuild \
|
|
the container. See docs/architecture.md for details.",
|
|
)
|
|
.into_response();
|
|
}
|
|
}
|
|
new.smb_host_override = new.smb_host_override.trim().to_string();
|
|
|
|
// Detect whether this PUT changes the Windows toggle, so we only
|
|
// restart smbd when it actually flipped.
|
|
let was_enabled = state.settings.snapshot().windows_enabled;
|
|
let want_enabled = new.windows_enabled;
|
|
state.settings.replace(new);
|
|
|
|
if let Some(smb) = &state.smb {
|
|
match (was_enabled, want_enabled) {
|
|
(false, true) => {
|
|
let _ = smb.start();
|
|
}
|
|
(true, false) => {
|
|
smb.stop();
|
|
}
|
|
(true, true) => {
|
|
let _ = smb.reconcile();
|
|
}
|
|
(false, false) => {}
|
|
}
|
|
}
|
|
StatusCode::NO_CONTENT.into_response()
|
|
}
|
|
|
|
// ─── Queued Deployment API ─────────────────────────────────────────────────
|
|
|
|
async fn api_list_queue(State(state): State<AppState>) -> Json<serde_json::Value> {
|
|
Json(json!({
|
|
"entries": state.queue.list(),
|
|
"count": state.queue.list().len(),
|
|
}))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct QueueJoinParams {
|
|
/// Client MAC from iPXE's `${mac}` variable. iPXE substitutes before
|
|
/// the HTTP request so we receive a plain colon-separated MAC.
|
|
mac: Option<String>,
|
|
}
|
|
|
|
/// Called by iPXE via `chain --replace`. We respond with a tiny iPXE
|
|
/// script that hard-loops on `/api/queue/poll/<id>`. iPXE keeps fetching
|
|
/// until poll returns an actual boot script.
|
|
async fn api_queue_join(
|
|
State(state): State<AppState>,
|
|
Query(p): Query<QueueJoinParams>,
|
|
headers: HeaderMap,
|
|
) -> Response {
|
|
let mac = p.mac.unwrap_or_else(|| "unknown".to_string());
|
|
let ip = headers
|
|
.get("x-forwarded-for")
|
|
.and_then(|h| h.to_str().ok())
|
|
.and_then(|s| s.split(',').next())
|
|
.and_then(|s| s.trim().parse().ok());
|
|
|
|
let queue_entry = state.queue.join(&mac, ip, None);
|
|
state.clients.record(
|
|
&mac,
|
|
ip,
|
|
None,
|
|
ClientEvent::HttpScriptFetch {
|
|
target: "queue-join".into(),
|
|
},
|
|
);
|
|
|
|
let base = &state.public_base_url;
|
|
// ASCII only - some iPXE builds on firmware consoles mangle non-ASCII.
|
|
let script = format!(
|
|
"#!ipxe\n\
|
|
echo\n\
|
|
echo ==========================================\n\
|
|
echo Queued Deployment - Queue Position {}\n\
|
|
echo Waiting for operator to assign an image\n\
|
|
echo (Ctrl-B returns to the iPXE shell)\n\
|
|
echo ==========================================\n\
|
|
chain {base}/api/queue/poll/{}\n",
|
|
queue_entry.position, queue_entry.id
|
|
);
|
|
text_plain(script)
|
|
}
|
|
|
|
/// Long-poll endpoint. Waits up to 25s for an assignment; if none, returns
|
|
/// a script that loops back to itself. 25s keeps us well inside typical
|
|
/// HTTP idle timeouts for iPXE and intermediaries.
|
|
async fn api_queue_poll(
|
|
State(state): State<AppState>,
|
|
AxumPath(entry_id): AxumPath<String>,
|
|
) -> Response {
|
|
let Some(notify) = state.queue.notifier(&entry_id) else {
|
|
// Queue entry was released; send client back to the main menu.
|
|
let base = &state.public_base_url;
|
|
return text_plain(format!("#!ipxe\nchain {base}/boot.ipxe\n"));
|
|
};
|
|
|
|
// Wait for an assignment or timeout.
|
|
let _ = tokio::time::timeout(Duration::from_secs(25), notify.notified()).await;
|
|
|
|
let snap = state.queue.touch(&entry_id);
|
|
let base = &state.public_base_url;
|
|
match snap {
|
|
// Bind `target` directly so we can't observe an Option::None between
|
|
// the guard and the unwrap (the old code had a race with concurrent
|
|
// `release`). We also do NOT release the queue entry here — the web UI
|
|
// operator releases it explicitly, which keeps a record of "this
|
|
// machine was assigned image X" visible until the client is known
|
|
// to have started. Clients that retry on transient network errors
|
|
// still get a valid boot script instead of falling back to the
|
|
// menu.
|
|
Some(g) if g.assigned_target.is_some() => {
|
|
let target = g.assigned_target.clone().unwrap_or_default();
|
|
tracing::info!(
|
|
target: "openpxe::queue",
|
|
entry_id=%entry_id, mac=%g.mac, target=%target,
|
|
"queue assignment delivered"
|
|
);
|
|
text_plain(format!(
|
|
"#!ipxe\n\
|
|
echo Queue assignment received: {target}\n\
|
|
chain {base}/boot/{target}.ipxe || chain {base}/api/queue/poll/{entry_id}\n"
|
|
))
|
|
}
|
|
Some(g) => {
|
|
// No assignment yet - loop and re-poll. Repaint position so the
|
|
// UI count stays accurate if other queue entries were released meanwhile.
|
|
text_plain(format!(
|
|
"#!ipxe\n\
|
|
echo Queue Position {} - still waiting\n\
|
|
chain {base}/api/queue/poll/{entry_id}\n",
|
|
g.position
|
|
))
|
|
}
|
|
None => text_plain(format!("#!ipxe\nchain {base}/boot.ipxe\n")),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct QueueAssignBody {
|
|
/// Boot entry id (from `BootEntry::id`). Same one used in
|
|
/// `/boot/<id>.ipxe`.
|
|
target: String,
|
|
/// Queue entry ids to assign. Empty = assign to all currently queued clients.
|
|
entry_ids: Vec<String>,
|
|
}
|
|
|
|
async fn api_queue_assign(
|
|
State(state): State<AppState>,
|
|
Json(body): Json<QueueAssignBody>,
|
|
) -> Json<serde_json::Value> {
|
|
let ids = if body.entry_ids.is_empty() {
|
|
state
|
|
.queue
|
|
.list()
|
|
.into_iter()
|
|
.map(|g| g.id)
|
|
.collect::<Vec<_>>()
|
|
} else {
|
|
body.entry_ids
|
|
};
|
|
// Guard: target must exist as a BootEntry id.
|
|
let found = state
|
|
.iso_store
|
|
.list()
|
|
.into_iter()
|
|
.any(|i| i.boot_entries.iter().any(|e| e.id == body.target));
|
|
if !found {
|
|
return Json(json!({ "ok": false, "error": "unknown target" }));
|
|
}
|
|
let n = state.queue.assign(&ids, &body.target);
|
|
Json(json!({ "ok": true, "assigned": n, "target": body.target }))
|
|
}
|
|
|
|
async fn api_queue_release(
|
|
State(state): State<AppState>,
|
|
AxumPath(entry_id): AxumPath<String>,
|
|
) -> StatusCode {
|
|
match state.queue.release(&entry_id) {
|
|
Some(_) => StatusCode::NO_CONTENT,
|
|
None => StatusCode::NOT_FOUND,
|
|
}
|
|
}
|
|
|
|
// ─── NFS share API ─────────────────────────────────────────────────────────
|
|
|
|
async fn api_nfs_list(State(state): State<AppState>) -> Json<serde_json::Value> {
|
|
Json(json!({ "mounts": state.nfs.list() }))
|
|
}
|
|
|
|
async fn api_nfs_add(State(state): State<AppState>, Json(req): Json<NfsAddRequest>) -> Response {
|
|
match state.nfs.add(req).await {
|
|
Ok(m) => (StatusCode::CREATED, Json(m)).into_response(),
|
|
// Anything from the manager surfaces as a user-fixable validation
|
|
// error — bad host, kernel without NFS support, missing
|
|
// `mount.nfs`, dead server. We pass the message through verbatim
|
|
// so the UI can show it to the operator.
|
|
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn api_nfs_remove(State(state): State<AppState>, AxumPath(id): AxumPath<String>) -> Response {
|
|
match state.nfs.remove(&id).await {
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn api_nfs_scan(State(state): State<AppState>, AxumPath(id): AxumPath<String>) -> Response {
|
|
match state.nfs.rescan(&id).await {
|
|
Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(),
|
|
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
|
}
|
|
}
|
|
|
|
// ─── Network info API ──────────────────────────────────────────────────────
|
|
|
|
async fn api_network(State(state): State<AppState>) -> Json<serde_json::Value> {
|
|
Json(json!({
|
|
"server_ip": state.public_base_url
|
|
.strip_prefix("http://")
|
|
.unwrap_or(&state.public_base_url),
|
|
"nic_name": state.nic_name,
|
|
"subnet_mask": state.subnet_mask,
|
|
"gateway": state.gateway,
|
|
"dns_server": state.settings.snapshot().dns_server,
|
|
"public_base_url": state.public_base_url,
|
|
}))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct NetworkPut {
|
|
/// Operators can set or clear an informational DNS hint. Server IP /
|
|
/// NIC / mask / gateway are auto-detected and not editable from the
|
|
/// UI — changing them in the wrong direction would silently break
|
|
/// PXE for every client.
|
|
dns_server: String,
|
|
}
|
|
|
|
async fn api_network_put(
|
|
State(state): State<AppState>,
|
|
Json(body): Json<NetworkPut>,
|
|
) -> StatusCode {
|
|
let mut s = state.settings.snapshot();
|
|
s.dns_server = body.dns_server.trim().to_string();
|
|
state.settings.replace(s);
|
|
StatusCode::NO_CONTENT
|
|
}
|
|
|
|
// ─── Per-MAC host bindings ────────────────────────────────────────────────
|
|
|
|
async fn api_hosts_list(State(state): State<AppState>) -> Json<serde_json::Value> {
|
|
Json(json!({ "hosts": state.hosts.list() }))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct HostsUpsertBody {
|
|
mac: String,
|
|
target: String,
|
|
#[serde(default)]
|
|
label: String,
|
|
}
|
|
|
|
async fn api_hosts_upsert(
|
|
State(state): State<AppState>,
|
|
Json(body): Json<HostsUpsertBody>,
|
|
) -> Response {
|
|
let mac = body.mac.trim();
|
|
if mac.is_empty() {
|
|
return (StatusCode::BAD_REQUEST, "mac is required").into_response();
|
|
}
|
|
// Sanity-check the target if the operator supplied a real boot
|
|
// entry id (anything starting with `_` is a reserved menu shortcut
|
|
// and exists by definition).
|
|
let target = body.target.trim();
|
|
if !target.starts_with('_')
|
|
&& !state
|
|
.iso_store
|
|
.list()
|
|
.into_iter()
|
|
.any(|i| i.boot_entries.iter().any(|e| e.id == target))
|
|
{
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
format!("unknown boot entry: {target}"),
|
|
)
|
|
.into_response();
|
|
}
|
|
let binding = state.hosts.upsert(mac, target, body.label.trim());
|
|
(StatusCode::CREATED, Json(binding)).into_response()
|
|
}
|
|
|
|
async fn api_hosts_remove(
|
|
State(state): State<AppState>,
|
|
AxumPath(mac): AxumPath<String>,
|
|
) -> StatusCode {
|
|
if state.hosts.remove(&mac) {
|
|
StatusCode::NO_CONTENT
|
|
} else {
|
|
StatusCode::NOT_FOUND
|
|
}
|
|
}
|
|
|
|
// ─── Boot event log ───────────────────────────────────────────────────────
|
|
|
|
async fn api_boot_log(State(state): State<AppState>) -> Json<serde_json::Value> {
|
|
Json(json!({ "events": state.boot_log.list() }))
|
|
}
|
|
|
|
// ─── Prometheus metrics ───────────────────────────────────────────────────
|
|
|
|
async fn api_metrics(State(state): State<AppState>) -> Response {
|
|
// Refresh gauges from live state before rendering — keeps the
|
|
// scrape "honest" without making /api/status the only path that
|
|
// updates them.
|
|
state
|
|
.metrics
|
|
.set_iso_count(state.iso_store.list().len() as u64);
|
|
state
|
|
.metrics
|
|
.set_client_count(state.clients.list().len() as u64);
|
|
let queue_entries = state.queue.list();
|
|
let imaging = queue_entries
|
|
.iter()
|
|
.filter(|entry| entry.assigned_target.is_some())
|
|
.count();
|
|
state
|
|
.metrics
|
|
.set_queue_counts(queue_entries.len() as u64, imaging as u64);
|
|
state
|
|
.metrics
|
|
.set_nfs_active(state.nfs.list().iter().filter(|m| m.mounted).count() as u64);
|
|
|
|
let now = time::OffsetDateTime::now_utc();
|
|
let uptime = (now - state.started_at).whole_seconds().max(0) as u64;
|
|
let body = state.metrics.render(env!("CARGO_PKG_VERSION"), uptime);
|
|
(
|
|
[(
|
|
header::CONTENT_TYPE,
|
|
HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"),
|
|
)],
|
|
body,
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn range_full() {
|
|
let (s, e, p) = parse_range(None, 1000).unwrap();
|
|
assert_eq!((s, e, p), (0, 999, false));
|
|
}
|
|
#[test]
|
|
fn range_open_ended() {
|
|
let h = HeaderValue::from_static("bytes=500-");
|
|
let (s, e, p) = parse_range(Some(&h), 1000).unwrap();
|
|
assert_eq!((s, e, p), (500, 999, true));
|
|
}
|
|
#[test]
|
|
fn range_suffix() {
|
|
let h = HeaderValue::from_static("bytes=-100");
|
|
let (s, e, p) = parse_range(Some(&h), 1000).unwrap();
|
|
assert_eq!((s, e, p), (900, 999, true));
|
|
}
|
|
#[test]
|
|
fn range_explicit() {
|
|
let h = HeaderValue::from_static("bytes=10-99");
|
|
let (s, e, p) = parse_range(Some(&h), 1000).unwrap();
|
|
assert_eq!((s, e, p), (10, 99, true));
|
|
}
|
|
|
|
#[test]
|
|
fn range_rejects_out_of_bounds_start() {
|
|
let h = HeaderValue::from_static("bytes=1000-");
|
|
let got = parse_range(Some(&h), 1000);
|
|
assert_eq!(got, None);
|
|
}
|
|
|
|
#[test]
|
|
fn range_rejects_start_after_end() {
|
|
let h = HeaderValue::from_static("bytes=99-10");
|
|
let got = parse_range(Some(&h), 1000);
|
|
assert_eq!(got, None);
|
|
}
|
|
}
|