Initial commit: PXEForge Phases 1-4
Container-native PXE boot server in Rust, designed as a clean-room alternative to iVentoy that never touches the client OS trust store. This is the first commit of the project; it lands the full output of Phases 1, 2, 3, and 4 in one shot. ## Phase 1 — protocol stack - 8-crate workspace (core, dhcp-proxy, tftp, http-api, iso-store, ipxe-assets, webui, pxeforge bin). - DHCP proxy (RFC 4578): replies with boot info only, never leases — sidesteps CAP_NET_RAW. Architecture-aware bootfile selection from option 93 (BIOS, IA32, x64-UEFI alias 0x0007/0x0009, ARM64). - TFTP server with full OACK negotiation: blksize, tsize, windowsize. Without it a 1 MiB iPXE binary takes 2000 packets and unusably long. - Two-stage iPXE chain: firmware PXE -> TFTP iPXE binary -> iPXE re-DHCPs with user-class iPXE -> HTTP /boot.ipxe -> kernel+initrd. - HTTP server (axum) with byte-Range ISO streaming and an in-place ISO9660 lookup so kernel/initrd are served from inside the ISO without ever extracting it to disk. - Linux ISOs boot via kernel+initrd extraction (memdisk/sanboot fail for >1-2 GiB modern distros). Distro-family detection drives the cmdline (Debian/Ubuntu, RHEL/Fedora, openSUSE, Arch, Alpine). ## Phase 2 — UX + Windows - Hierarchical PXE menu (Default / Installers / Tools / Gated Deployment) generated from settings — no hand-written .ipxe paths surface in the UI. Number-key + letter hotkeys, BIOS+UEFI variants for some RHEL ISOs. - Gated Deployment "horse-race" queue: clients join, operator picks one ISO, every gate launches simultaneously via tokio::sync::Notify. - Bootimus-pattern Windows: WimPatcher injects a CRLF startnet.cmd into boot.wim so vanilla WinPE net-uses an SMB share and runs setup.exe. All Microsoft-signed; no test certs, no testsigning, no httpdisk.sys. SmbManager supervises smbd start/stop/SIGHUP. - Netbox-style dark UI, fully offline (no CDN, no external fonts). ## Phase 3 — MVP hardening - TFTP retransmit rewrite with explicit window tracking — UEFI SNP clients no longer hang on files that end mid-window. 4 new tests. - DHCP broadcast-flag honored per RFC 2131 §4.1. - Multi-arch container (linux/amd64 + linux/arm64). Entrypoint chowns bind-mounts as root then drops to uid 10001 via gosu. - /healthz + /readyz split from /api/status — readyz fails if no iPXE binaries are bundled. - pxeforge seed --from <path> CLI: same pipeline as web upload (slug, sha256, introspection, boot-entry). - All timestamps RFC 3339 (browser Date couldn't parse the 9-tuple). - Gate poll retains assignment until operator releases — clients that retry on transient network errors reuse the assignment instead of falling back to the menu. - Custom OpenShift SCC: hostNetwork + NET_BIND_SERVICE only, no NET_RAW. ## Phase 4 — UI restructure + remote storage - Web UI rebuilt around six tabs inspired by the iVentoy layout: Dashboard / Network / Forge Gate / Storage / Terminal / About. Old "Monitoring/Content/Configuration" sidebar groups are gone. - NFS share manager (crates/iso-store/src/nfs.rs): mount NFSv3 or NFSv4.1 shares as ISO sources instead of uploading every file into the PVC. New IsoSource enum on IsoMeta lets the store resolve Local vs NFS lazily. Persisted to <work_dir>/nfs.json; failed mounts surface in the UI rather than blocking startup. - Dockerfile gains nfs-common + iproute2; mounting NFS in-container also requires CAP_SYS_ADMIN. Documented in docs/architecture.md. - LogBus + tracing layer in core: 500-line ring buffer + broadcast channel feed an SSE endpoint at /api/log/stream. - Operator terminal at /api/terminal: whitelisted commands (status, isos, clients, gate, nfs, smb, log) — deliberately not a shell. Output mirrored onto the LogBus so the live tail and the terminal pane share one timeline. - Network tab: read-only nic_name / subnet_mask / gateway probed from `ip` at startup; only DNS server is editable. Editing IP/mask on a hot UI would silently break PXE for every client mid-boot. - Bootimus parity (releases v0.1.55 -> v0.1.62): amber row tint on un-bootable ISOs with inline reasons, dashboard "won't boot" panel. ## Tests 56 tests passing across the workspace: - 16 core (LogBus, gate, settings, arch, client) - 1 dhcp-proxy (raw option-93 extraction) - 8 http-api unit (range parsing, terminal split/format) - 13 http-api integration (gated deployment, range, settings, NFS, terminal, log SSE, network endpoint, ui assets, no-external-urls) - 12 iso-store (introspect, slugify, smb, windows wim, NFS options) - 6 tftp (RRQ parsing, plan_window edges) cargo build --workspace and cargo clippy --workspace --all-targets both finish clean (warnings only, no errors).
This commit is contained in:
@@ -0,0 +1,680 @@
|
||||
//! Axum router, handlers, and API endpoints.
|
||||
//!
|
||||
//! Route groups:
|
||||
//!
|
||||
//! | Group | Purpose |
|
||||
//! |------------------|-------------------------------------------------------|
|
||||
//! | `/` | Web UI (served from `pxeforge-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_gate_entry, render_local_hdd,
|
||||
render_menu, render_nic_info, 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,
|
||||
extract::{DefaultBodyLimit, Multipart, Path as AxumPath, Query, State},
|
||||
http::{header, HeaderMap, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use pxeforge_core::{ClientEvent, Settings};
|
||||
use pxeforge_ipxe_assets::asset_bytes;
|
||||
use pxeforge_iso_store::{IsoMeta, NfsAddRequest};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
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))
|
||||
// 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/clients", get(api_list_clients))
|
||||
.route("/api/status", get(api_status))
|
||||
.route("/api/settings", get(api_get_settings).put(api_put_settings))
|
||||
.route("/api/gate", get(api_list_gates))
|
||||
.route("/api/gate/join", get(api_gate_join))
|
||||
.route("/api/gate/poll/:gate_id", get(api_gate_poll))
|
||||
.route("/api/gate/assign", post(api_gate_assign))
|
||||
.route("/api/gate/:gate_id", delete(api_gate_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))
|
||||
.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 = pxeforge_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"))],
|
||||
pxeforge_webui::app_js(),
|
||||
).into_response()
|
||||
}
|
||||
|
||||
async fn ui_css() -> Response {
|
||||
(
|
||||
[(header::CONTENT_TYPE, HeaderValue::from_static("text/css"))],
|
||||
pxeforge_webui::app_css(),
|
||||
).into_response()
|
||||
}
|
||||
|
||||
async fn ui_logo() -> Response {
|
||||
(
|
||||
[(header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml"))],
|
||||
pxeforge_webui::logo_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()
|
||||
}
|
||||
|
||||
async fn boot_top_menu(State(state): State<AppState>) -> Response {
|
||||
let isos = state.iso_store.list();
|
||||
let settings = state.settings.snapshot();
|
||||
text_plain(render_menu(&isos, &settings, &state.public_base_url))
|
||||
}
|
||||
|
||||
async fn boot_sub(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(filename): AxumPath<String>,
|
||||
) -> Response {
|
||||
// `/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(base),
|
||||
"_util" => render_util(base),
|
||||
"_shell" => render_shell(base),
|
||||
"_nic" => render_nic_info(base),
|
||||
"_gate" => render_gate_entry(base),
|
||||
other => {
|
||||
for iso in &isos {
|
||||
for entry in &iso.boot_entries {
|
||||
if entry.id == other {
|
||||
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();
|
||||
let (start, end, partial) = parse_range(range, total);
|
||||
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) -> (u64, u64, bool) {
|
||||
let Some(h) = h else { return (0, total.saturating_sub(1), false); };
|
||||
let Ok(s) = h.to_str() else { return (0, total.saturating_sub(1), false); };
|
||||
let Some(spec) = s.strip_prefix("bytes=") else { return (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 (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));
|
||||
(start, end.min(total.saturating_sub(1)), 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,
|
||||
}
|
||||
}
|
||||
|
||||
async fn api_upload_iso(
|
||||
State(state): State<AppState>,
|
||||
mut multipart: Multipart,
|
||||
) -> Response {
|
||||
while let Ok(Some(mut field)) = multipart.next_field().await {
|
||||
if field.name() != Some("file") { continue; }
|
||||
let filename = field.file_name().unwrap_or("uploaded.iso").to_string();
|
||||
if !filename.to_ascii_lowercase().ends_with(".iso") {
|
||||
return (StatusCode::BAD_REQUEST, "only .iso uploads accepted").into_response();
|
||||
}
|
||||
let mut handle = match state.iso_store.begin_upload(&filename).await {
|
||||
Ok(h) => h,
|
||||
Err(e) => return (StatusCode::CONFLICT, format!("{e}")).into_response(),
|
||||
};
|
||||
while let Ok(Some(chunk)) = field.chunk().await {
|
||||
if let Err(e) = handle.write_chunk(&chunk).await {
|
||||
let _ = handle.abort().await;
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response();
|
||||
}
|
||||
}
|
||||
let meta = match handle.finish(&state.iso_store).await {
|
||||
Ok(m) => m,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||
};
|
||||
return (StatusCode::CREATED, Json(meta)).into_response();
|
||||
}
|
||||
(StatusCode::BAD_REQUEST, "no 'file' part").into_response()
|
||||
}
|
||||
|
||||
// ─── 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 = pxeforge_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 gates = state.gates.list();
|
||||
// Phase 4: dashboard tracks "imaging" as gates with an assignment
|
||||
// already issued — they're the ones actively chaining a boot script.
|
||||
let imaging = gates.iter().filter(|g| g.assigned_target.is_some()).count();
|
||||
let waiting = gates.len() - imaging;
|
||||
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": state.clients.list().len(),
|
||||
"gate_count": gates.len(),
|
||||
"imaging_count": imaging,
|
||||
"waiting_count": waiting,
|
||||
"ipxe_assets": pxeforge_ipxe_assets::list_assets(),
|
||||
"settings": state.settings.snapshot(),
|
||||
"smb": smb,
|
||||
"nfs_count": nfs.len(),
|
||||
"nfs_active": nfs_active,
|
||||
"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 = pxeforge_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()
|
||||
}
|
||||
|
||||
// ─── Gated Deployment API ─────────────────────────────────────────────────
|
||||
|
||||
async fn api_list_gates(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
Json(json!({
|
||||
"gates": state.gates.list(),
|
||||
"count": state.gates.list().len(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GateJoinParams {
|
||||
/// 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/gate/poll/<id>`. iPXE keeps fetching
|
||||
/// until poll returns an actual boot script.
|
||||
async fn api_gate_join(
|
||||
State(state): State<AppState>,
|
||||
Query(p): Query<GateJoinParams>,
|
||||
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 gate = state.gates.join(&mac, ip, None);
|
||||
state.clients.record(
|
||||
&mac, ip, None,
|
||||
ClientEvent::HttpScriptFetch { target: "gate-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 Gated Deployment - Gate 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/gate/poll/{}\n",
|
||||
gate.position, gate.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_gate_poll(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(gate_id): AxumPath<String>,
|
||||
) -> Response {
|
||||
let Some(notify) = state.gates.notifier(&gate_id) else {
|
||||
// Gate 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.gates.touch(&gate_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 gate 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: "pxeforge::gate",
|
||||
gate_id=%gate_id, mac=%g.mac, target=%target,
|
||||
"gate assignment delivered"
|
||||
);
|
||||
text_plain(format!(
|
||||
"#!ipxe\n\
|
||||
echo Gate assignment received: {target}\n\
|
||||
chain {base}/boot/{target}.ipxe || chain {base}/api/gate/poll/{gate_id}\n"
|
||||
))
|
||||
}
|
||||
Some(g) => {
|
||||
// No assignment yet - loop and re-poll. Repaint position so the
|
||||
// UI count stays accurate if other gates were released meanwhile.
|
||||
text_plain(format!(
|
||||
"#!ipxe\n\
|
||||
echo Gate Position {} - still waiting\n\
|
||||
chain {base}/api/gate/poll/{gate_id}\n",
|
||||
g.position
|
||||
))
|
||||
}
|
||||
None => text_plain(format!("#!ipxe\nchain {base}/boot.ipxe\n")),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GateAssignBody {
|
||||
/// Boot entry id (from `BootEntry::id`). Same one used in
|
||||
/// `/boot/<id>.ipxe`.
|
||||
target: String,
|
||||
/// Gate ids to assign. Empty = assign to all currently queued gates.
|
||||
gate_ids: Vec<String>,
|
||||
}
|
||||
|
||||
async fn api_gate_assign(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<GateAssignBody>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let ids = if body.gate_ids.is_empty() {
|
||||
state.gates.list().into_iter().map(|g| g.id).collect::<Vec<_>>()
|
||||
} else {
|
||||
body.gate_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.gates.assign(&ids, &body.target);
|
||||
Json(json!({ "ok": true, "assigned": n, "target": body.target }))
|
||||
}
|
||||
|
||||
async fn api_gate_release(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(gate_id): AxumPath<String>,
|
||||
) -> StatusCode {
|
||||
match state.gates.release(&gate_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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn range_full() {
|
||||
let (s, e, p) = parse_range(None, 1000);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
assert_eq!((s, e, p), (10, 99, true));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user