Name update
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