Name update

This commit is contained in:
Miles Ward
2026-04-29 02:47:00 -04:00
commit 3517c67831
66 changed files with 9016 additions and 0 deletions
+680
View File
@@ -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));
}
}
+327
View File
@@ -0,0 +1,327 @@
//! iPXE script generator.
//!
//! ## Menu hierarchy (per Phase 2 spec)
//!
//! ```text
//! Top level:
//! Default
//! > Boot from Local HDD
//! Installers
//! > Linux Installers -> submenu of Linux ISOs
//! > Windows Installers -> submenu of Windows ISOs (gated by Settings::windows_enabled)
//! Tools
//! > Utilities -> memtest, etc. (embedded assets only)
//! > PXEForge Shell -> drop to iPXE shell with branded prompt
//! > Network Card Info -> ifstat / config / route dump
//! Gated Deployment -> join the gate queue
//! ```
//!
//! ## iPXE is entirely backend — users do not see or write iPXE
//!
//! All user-facing knobs live in `Settings`. Script generation translates
//! those knobs into iPXE primitives (chain, menu, item, choose, etc.).
//! There is intentionally no UI path to upload a custom `.ipxe` script.
use pxeforge_core::{Settings, TimeoutAction};
use pxeforge_iso_store::{BootEntry, BootKind, IsoMeta};
use pxeforge_iso_store::introspect::DistroFamily;
use std::fmt::Write as _;
/// Top-level PXEForge boot menu. Serialized identically for BIOS and UEFI
/// clients because iPXE normalises the menu primitives across firmwares.
#[must_use]
pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String {
let mut s = String::new();
let base = base_url.trim_end_matches('/');
let timeout_ms = settings.boot_menu_timeout_secs.saturating_mul(1000);
let default_item = match settings.timeout_action {
TimeoutAction::LocalHdd => "local",
TimeoutAction::GatedDeployment => "gate",
// Stay -> iPXE's `--timeout 0` is "no timeout". Pick any default
// label; the client waits for keypress.
TimeoutAction::Stay => "local",
};
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "# PXEForge top-level menu - auto-generated, do not edit");
let _ = writeln!(s, "set base-url {base}");
let _ = writeln!(s, "set esc:hex 1b");
let _ = writeln!(s, "set cls ${{esc:string}}[2J");
let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu PXEForge - network boot menu");
let _ = writeln!(s, "item --gap -- ------------------------- Default -------------------------");
let _ = writeln!(s, "item local Boot from Local HDD");
let _ = writeln!(s, "item --gap -- ----------------------- Installers -----------------------");
if has_family(isos, is_linux_family) {
let _ = writeln!(s, "item linux Linux Installers >");
} else {
let _ = writeln!(s, "item --gap -- (no Linux ISOs uploaded)");
}
if settings.windows_enabled && has_family(isos, is_windows_family) {
let _ = writeln!(s, "item windows Windows Installers >");
} else if settings.windows_enabled {
let _ = writeln!(s, "item --gap -- (no Windows ISOs uploaded)");
} else {
let _ = writeln!(s, "item --gap -- (Windows support disabled in Settings)");
}
let _ = writeln!(s, "item --gap -- -------------------------- Tools --------------------------");
let _ = writeln!(s, "item tools Tools >");
let _ = writeln!(s, "item --gap -- ---------------------- Gated Deployment ---------------------");
let _ = writeln!(s, "item gate Gated Deployment (join queue)");
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key x exit Exit iPXE");
if matches!(settings.timeout_action, TimeoutAction::Stay) {
let _ = writeln!(s, "choose --default {default_item} target || goto menu");
} else {
let _ = writeln!(s, "choose --default {default_item} --timeout {timeout_ms} target || goto menu");
}
// iPXE's `||` is strict about what follows. Each test uses `goto menu`
// as the fallthrough target so the parser never sees a bare `||` with
// trailing whitespace — some iPXE builds reject that.
let _ = writeln!(s, "iseq ${{target}} local && chain {base}/boot/_local.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} linux && chain {base}/boot/_linux_menu.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} windows && chain {base}/boot/_windows_menu.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} tools && chain {base}/boot/_tools_menu.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} gate && chain {base}/boot/_gate.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} exit && exit || goto menu");
let _ = writeln!(s, "goto menu");
s
}
/// Per-family submenu (Linux or Windows). Each item shows the ISO size
/// in MiB, iVentoy-style (`[ 4376 MB] ubuntu-22.04-desktop-amd64`).
#[must_use]
pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) -> String {
let base = base_url.trim_end_matches('/');
let title = if is_windows { "Windows Installers" } else { "Linux Installers" };
let label = if is_windows { "windows" } else { "linux" };
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "set base-url {base}");
let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu PXEForge - {title}");
let filter: fn(DistroFamily) -> bool =
if is_windows { is_windows_family } else { is_linux_family };
let mut count = 0;
for iso in isos {
if !filter(iso.introspection.family) { continue; }
for entry in &iso.boot_entries {
let size_label = fmt_size_mib(iso.size_bytes);
let key = hotkey_for_index(count);
let _ = writeln!(
s, "item {}{} [{:>6}] {}",
key,
entry.id,
size_label,
escape_label(&entry.title),
);
count += 1;
}
}
if count == 0 {
let _ = writeln!(s, "item --gap -- (no {label} images uploaded yet)");
}
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key b back < Back to main menu");
let _ = writeln!(s, "choose target || goto menu");
let _ = writeln!(s, "iseq ${{target}} back && chain {base}/boot.ipxe || goto menu");
let _ = writeln!(s, "chain {base}/boot/${{target}}.ipxe || goto menu");
s
}
/// Format a byte count as `NNNN MB` (iVentoy-style — MB not MiB, to match
/// operator expectations from the original tool).
fn fmt_size_mib(bytes: u64) -> String {
let mib = bytes / (1024 * 1024);
format!("{} MB", mib)
}
/// Assign `--key N <id>` hotkeys 1..9, then nothing for positions >=9.
/// iPXE's menu needs the --key prefix as a separate token before the id.
fn hotkey_for_index(i: usize) -> String {
if i < 9 {
format!("--key {} ", i + 1)
} else {
String::new()
}
}
/// Tools submenu — Utilities, Shell, NIC Info, Reboot, Exit to firmware.
#[must_use]
pub fn render_tools_menu(base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "set base-url {base}");
let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu PXEForge - Tools");
let _ = writeln!(s, "item --key u util Utilities (memtest, ...)");
let _ = writeln!(s, "item --key s shell PXEForge Shell");
let _ = writeln!(s, "item --key n nic Network Card Info");
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key r reboot Reboot Computer");
let _ = writeln!(s, "item --key e firmware Exit and continue BIOS boot");
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key b back < Back to main menu");
let _ = writeln!(s, "choose target || goto menu");
let _ = writeln!(s, "iseq ${{target}} util && chain {base}/boot/_util.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} shell && chain {base}/boot/_shell.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} nic && chain {base}/boot/_nic.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} reboot && reboot || goto menu");
let _ = writeln!(s, "iseq ${{target}} firmware && exit 0 || goto menu");
let _ = writeln!(s, "iseq ${{target}} back && chain {base}/boot.ipxe || goto menu");
let _ = writeln!(s, "goto menu");
s
}
/// "Boot from Local HDD". On BIOS, we sanboot the first local drive; on
/// UEFI we `exit` so the firmware moves to the next boot entry (normally
/// the internal disk).
#[must_use]
pub fn render_local_hdd(base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "# Boot from Local HDD - platform-sensitive");
let _ = writeln!(s, "iseq ${{platform}} pcbios && sanboot --no-describe --drive 0x80 || ");
let _ = writeln!(s, "# UEFI path: fall through to the firmware's next boot entry");
let _ = writeln!(s, "exit 0");
let _ = writeln!(s, "# If the above exit returns, loop back to the main menu");
let _ = writeln!(s, "chain {base}/boot.ipxe");
s
}
/// Utilities submenu. For Phase 2 we bundle memtest86+ as an optional
/// asset (if absent, the item is listed but errors gracefully). No third-
/// party tools are fetched at runtime.
#[must_use]
pub fn render_util(base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu PXEForge - Utilities");
let _ = writeln!(s, "item memtest MemTest86+ (RAM diagnostic)");
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item back < Back");
let _ = writeln!(s, "choose target || goto menu");
let _ = writeln!(s, "iseq ${{target}} memtest && chain {base}/ipxe/memtest.bin || ");
let _ = writeln!(s, "iseq ${{target}} back && chain {base}/boot/_tools_menu.ipxe || ");
let _ = writeln!(s, "goto menu");
s
}
/// "PXEForge Shell" — iPXE shell, branded.
#[must_use]
pub fn render_shell(base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "echo ==========================================");
let _ = writeln!(s, "echo PXEForge Shell");
let _ = writeln!(s, "echo 'exit' returns to the main menu");
let _ = writeln!(s, "echo ==========================================");
let _ = writeln!(s, "shell");
let _ = writeln!(s, "chain {base}/boot.ipxe");
s
}
/// "Network Card Info" — print ifstat + route + config.
#[must_use]
pub fn render_nic_info(base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "echo ==========================================");
let _ = writeln!(s, "echo Network Card Info");
let _ = writeln!(s, "echo ==========================================");
let _ = writeln!(s, "ifstat");
let _ = writeln!(s, "echo");
let _ = writeln!(s, "route");
let _ = writeln!(s, "echo");
let _ = writeln!(s, "echo 'Press any key to return to menu'");
let _ = writeln!(s, "prompt --timeout 30000");
let _ = writeln!(s, "chain {base}/boot.ipxe");
s
}
/// Gated Deployment entry point. Joins the queue, then enters a long-poll
/// loop (iPXE repeats the chain on 3xx redirects / HTTP errors until a
/// real script comes back).
#[must_use]
pub fn render_gate_entry(base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "# Gated Deployment - join the queue and wait for operator");
let _ = writeln!(s, "echo Joining gate queue...");
// imgfetch writes the body to a file in iPXE's transient FS; we read
// the gate id out of the Location-style header by asking the server
// to put it in the response body as a single token.
let _ = writeln!(s, "chain --replace {base}/api/gate/join?mac=${{mac}}");
s
}
/// Per-entry boot script (same as Phase 1, with extra_kernel_args appended).
#[must_use]
pub fn render_entry(entry: &BootEntry, settings: &Settings, base_url: &str) -> String {
let mut s = String::new();
let base = base_url.trim_end_matches('/');
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "set base-url {base}");
match &entry.kind {
BootKind::LinuxKernel { kernel_url, initrd_urls, args } => {
let mut cmdline = args.cmdline.replace("${base-url}", base);
if !settings.extra_kernel_args.trim().is_empty() {
cmdline.push(' ');
cmdline.push_str(settings.extra_kernel_args.trim());
}
let _ = writeln!(s, "kernel {base}/{kernel_url} {cmdline}");
for u in initrd_urls {
let _ = writeln!(s, "initrd {base}/{u}");
}
let _ = writeln!(s, "boot || goto failed");
}
BootKind::Wimboot { wimboot_url, files } => {
let _ = writeln!(s, "kernel {base}/{wimboot_url}");
for (tag, url) in files {
let _ = writeln!(s, "initrd --name {tag} {base}/{url} {tag}");
}
let _ = writeln!(s, "boot || goto failed");
}
BootKind::SanBootIso { iso_url } => {
let _ = writeln!(s, "sanboot --no-describe {base}/{iso_url} || goto failed");
}
}
let _ = writeln!(s, ":failed");
let _ = writeln!(s, "echo Boot failed - returning to menu in 5s");
let _ = writeln!(s, "sleep 5");
let _ = writeln!(s, "chain {base}/boot.ipxe");
s
}
fn is_linux_family(f: DistroFamily) -> bool {
matches!(
f,
DistroFamily::DebianUbuntu
| DistroFamily::RhelFedora
| DistroFamily::OpenSuse
| DistroFamily::Arch
| DistroFamily::Alpine
| DistroFamily::Unknown
)
}
fn is_windows_family(f: DistroFamily) -> bool {
matches!(f, DistroFamily::WindowsPe)
}
fn has_family(isos: &[IsoMeta], pred: fn(DistroFamily) -> bool) -> bool {
isos.iter().any(|i| pred(i.introspection.family))
}
fn escape_label(s: &str) -> String {
s.chars().map(|c| match c { '\n' | '\r' => ' ', c => c }).collect()
}
+112
View File
@@ -0,0 +1,112 @@
//! Minimal read-only ISO9660 lookup. Given an uploaded ISO file and an
//! in-ISO path (e.g. `/casper/vmlinuz`), locate the file and return a
//! `(start_byte, length_bytes)` pair so the HTTP handler can stream just
//! that range from the on-disk ISO without full extraction.
//!
//! We only implement what we need: the Primary Volume Descriptor and Rock
//! Ridge / Joliet extensions are ignored. Paths are matched case-insensitive
//! against plain ISO9660 filenames (uppercase, `;1` version suffix stripped).
//! This is sufficient for the kernel/initrd and wimboot files we serve;
//! if a requested path isn't found, the handler returns 404 and the user
//! can still download the whole ISO via `/iso/<id>.iso`.
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
const SECTOR: u64 = 2048;
#[derive(Debug, Clone)]
pub struct FileLocation {
pub offset: u64,
pub length: u64,
}
/// Look up `in_iso_path` (leading slash optional, case-insensitive) in the
/// ISO at `iso_path`. Returns None on any parsing or IO failure.
pub fn lookup(iso_path: &Path, in_iso_path: &str) -> Option<FileLocation> {
let mut f = std::fs::File::open(iso_path).ok()?;
let root = read_root_directory(&mut f)?;
let components: Vec<&str> = in_iso_path
.trim_start_matches('/')
.split('/')
.filter(|c| !c.is_empty())
.collect();
if components.is_empty() { return None; }
walk(&mut f, root.offset, root.length, &components)
}
fn read_root_directory(f: &mut std::fs::File) -> Option<FileLocation> {
// Primary Volume Descriptor at LBA 16.
let mut pvd = [0u8; 2048];
f.seek(SeekFrom::Start(16 * SECTOR)).ok()?;
f.read_exact(&mut pvd).ok()?;
if pvd[0] != 0x01 || &pvd[1..6] != b"CD001" { return None; }
// Root directory record is at offset 156, length 34.
let rec = &pvd[156..156 + 34];
let (offset, length) = parse_dir_record_ext(rec)?;
Some(FileLocation { offset: offset * SECTOR, length })
}
/// Walk components down the directory tree starting at `dir_offset`.
fn walk(
f: &mut std::fs::File,
dir_offset: u64,
dir_len: u64,
components: &[&str],
) -> Option<FileLocation> {
let mut dir = vec![0u8; dir_len as usize];
f.seek(SeekFrom::Start(dir_offset)).ok()?;
f.read_exact(&mut dir).ok()?;
let target = components[0];
let rest = &components[1..];
let mut i = 0;
while i < dir.len() {
let len = dir[i] as usize;
if len == 0 {
// Padding to sector boundary.
let next = (i / SECTOR as usize + 1) * SECTOR as usize;
if next <= i { break; }
i = next;
continue;
}
if i + len > dir.len() { break; }
let rec = &dir[i..i + len];
let name = dir_record_name(rec);
let is_dir = (rec.get(25).copied().unwrap_or(0) & 0x02) != 0;
// Skip "." (0x00) and ".." (0x01) pseudo-entries.
let is_pseudo = matches!(rec.get(32).copied(), Some(1)) && rec.get(33).copied() == Some(0x00)
|| matches!(rec.get(32).copied(), Some(1)) && rec.get(33).copied() == Some(0x01);
if !is_pseudo && name.eq_ignore_ascii_case(target) {
let (child_off, child_len) = parse_dir_record_ext(rec)?;
if rest.is_empty() && !is_dir {
return Some(FileLocation { offset: child_off * SECTOR, length: child_len });
} else if !rest.is_empty() && is_dir {
return walk(f, child_off * SECTOR, child_len, rest);
}
}
i += len;
}
None
}
/// Extract (extent LBA, data length in bytes) from a directory record.
/// Layout per ISO9660: bytes 2..10 extent LBA (LE+BE duplicate), 10..18
/// data length (LE+BE duplicate). We trust the little-endian copy.
fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> {
if rec.len() < 34 { return None; }
let lba = u32::from_le_bytes(rec[2..6].try_into().ok()?) as u64;
let len = u32::from_le_bytes(rec[10..14].try_into().ok()?) as u64;
Some((lba, len))
}
/// Extract the identifier from a directory record, stripping ISO9660's
/// `;1` version suffix.
fn dir_record_name(rec: &[u8]) -> String {
let name_len = *rec.get(32).unwrap_or(&0) as usize;
if name_len == 0 || rec.len() < 33 + name_len { return String::new(); }
let raw = &rec[33..33 + name_len];
let s = String::from_utf8_lossy(raw).to_string();
// Strip `;N` version suffix.
if let Some(i) = s.rfind(';') { s[..i].to_string() } else { s }
}
+24
View File
@@ -0,0 +1,24 @@
//! HTTP server — single axum app that serves:
//! - `/` the web UI (static assets from `pxeforge-webui`)
//! - `/api/*` JSON API for the web UI
//! - `/boot.ipxe` the generated top-level iPXE boot menu
//! - `/boot/<entry>.ipxe` per-entry iPXE scripts (one per boot target)
//! - `/ipxe/<file>` bundled iPXE binaries (for UEFI HTTP boot)
//! - `/iso/<id>.iso` raw ISO file (with Range support)
//! - `/iso/<id>/<path>` files inside the ISO (for wimboot WIM fetches
//! and Linux kernel/initrd, without having to
//! re-extract on every request)
//!
//! The `<id>/<path>` handler uses a read-only ISO9660 shim (see `iso_fs`)
//! that lseeks into the ISO on disk — so we never keep extracted copies.
#![forbid(unsafe_code)]
pub mod app;
pub mod ipxe_script;
pub mod iso_fs;
pub mod log_stream;
pub mod state;
pub mod terminal;
pub use app::build_router;
pub use state::AppState;
+66
View File
@@ -0,0 +1,66 @@
//! Server-Sent Events stream for the Terminal tab's live log pane.
//!
//! On connection we emit the recent ring buffer (so the UI doesn't open
//! to a blank pane), then forward every new line from the broadcast
//! channel. Slow clients that fall behind get a `lagged` event and
//! resume — better than dropping the connection mid-tail.
use crate::state::AppState;
use axum::{
extract::State,
response::sse::{Event, KeepAlive, Sse},
Json,
};
use futures::stream::{Stream, StreamExt};
use pxeforge_core::LogLine;
use serde_json::json;
use std::convert::Infallible;
use std::time::Duration;
use tokio_stream::wrappers::BroadcastStream;
/// SSE handler. Each `data:` payload is a JSON object matching `LogLine`.
pub async fn stream(
State(state): State<AppState>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
// 1. Snapshot the recent buffer first so a fresh UI sees context.
let recent = state.log_bus.recent();
let recent_stream = futures::stream::iter(
recent
.into_iter()
.map(|l| Ok(Event::default().data(line_json(&l)))),
);
// 2. Then live updates. BroadcastStream yields Result<T, Lagged>; on
// a lagged client we send a synthetic event so the UI can flag it
// rather than silently dropping data.
let rx = state.log_bus.subscribe();
let live = BroadcastStream::new(rx).map(|res| match res {
Ok(line) => Ok(Event::default().data(line_json(&line))),
Err(tokio_stream::wrappers::errors::BroadcastStreamRecvError::Lagged(n)) => Ok(Event::default()
.event("lagged")
.data(json!({ "skipped": n }).to_string())),
});
Sse::new(recent_stream.chain(live))
.keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
}
/// Plain JSON snapshot of the recent buffer, for clients that prefer a
/// pull-based fetch over an SSE subscription.
pub async fn recent(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!({ "lines": state.log_bus.recent() }))
}
/// Drop the in-memory ring buffer. Live subscribers are unaffected (they
/// keep streaming new lines as they arrive).
pub async fn clear(State(state): State<AppState>) -> Json<serde_json::Value> {
state.log_bus.clear();
state
.log_bus
.push("info", "pxeforge::terminal", "log buffer cleared by operator");
Json(json!({ "ok": true }))
}
fn line_json(l: &LogLine) -> String {
serde_json::to_string(l).unwrap_or_else(|_| "{}".to_string())
}
+38
View File
@@ -0,0 +1,38 @@
use pxeforge_core::{ClientRegistry, GateQueue, LogBus, SettingsStore};
use pxeforge_iso_store::{IsoStore, NfsManager, SmbManager};
use std::sync::Arc;
use time::OffsetDateTime;
#[derive(Clone)]
pub struct AppState {
pub iso_store: IsoStore,
pub clients: Arc<ClientRegistry>,
pub settings: Arc<SettingsStore>,
pub gates: Arc<GateQueue>,
/// Optional SMB manager. Present when the binary was given a writable
/// `smb_dir` at startup; `None` in pure-Linux-only deployments where
/// Windows support is not wired in. Settings toggle drives start/stop.
pub smb: Option<Arc<SmbManager>>,
/// NFS share manager. Always present (mounting is opt-in by the
/// operator from the Storage tab); `add()` requires `mount.nfs` to be
/// available in the runtime image. Surfaces errors per-mount rather
/// than failing the global state.
pub nfs: NfsManager,
/// Live log bus consumed by the Terminal tab via SSE. Operator-issued
/// terminal commands also push synthetic lines onto it so the tail
/// shows them inline.
pub log_bus: Arc<LogBus>,
/// Wall-clock instant the server bound — used for the uptime chip.
pub started_at: OffsetDateTime,
/// Base URL advertised to PXE clients (e.g. `http://10.0.0.5`). Used when
/// rendering iPXE scripts so every URL resolves offline.
pub public_base_url: String,
/// Name of the network interface auto-detected at startup (e.g.
/// `enp1s0`). Surfaced read-only on the Network tab. Empty if the
/// interface couldn't be identified.
pub nic_name: String,
/// Subnet mask of the public interface in dotted-quad form.
pub subnet_mask: String,
/// Default gateway IPv4 address.
pub gateway: String,
}
+524
View File
@@ -0,0 +1,524 @@
//! Operator terminal — typed commands over HTTP.
//!
//! The Terminal tab posts a single command line per request. We split it
//! into argv, dispatch to a whitelisted handler, and return plain-text
//! output. The handler also pushes the input line and any output onto
//! the LogBus so commands and their results show up inline in the live
//! tail (Minecraft-server-style).
//!
//! ## Why a whitelist
//!
//! Exposing a real shell would be a remote-code-execution endpoint. We
//! keep the surface tiny and read-mostly; mutations are limited to the
//! same operations the rest of the UI already exposes.
use crate::state::AppState;
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use serde::Deserialize;
use serde_json::json;
use std::fmt::Write as _;
#[derive(Debug, Deserialize)]
pub struct CommandRequest {
/// Raw input as typed by the operator. Empty / all-whitespace is OK
/// (returns the help banner).
pub command: String,
}
pub async fn run_command(
State(state): State<AppState>,
Json(req): Json<CommandRequest>,
) -> impl IntoResponse {
let line = req.command.trim();
if line.is_empty() {
return (StatusCode::OK, Json(json!({ "output": HELP_TEXT, "ok": true })));
}
// Echo the typed command into the live log so the Terminal tab shows
// operator activity in-band with server-emitted log lines.
state.log_bus.push("info", "pxeforge::terminal", format!("> {line}"));
let argv = shell_split(line);
if argv.is_empty() {
return (
StatusCode::OK,
Json(json!({ "output": HELP_TEXT, "ok": true })),
);
}
let result = dispatch(&state, &argv).await;
let (ok, output) = match result {
Ok(s) => (true, s),
Err(s) => (false, s),
};
// Mirror command output to the log bus (truncated for noisy commands)
// so reading the live tail tells the same story as scrolling the
// terminal pane.
let mirror = if output.len() > 1024 {
format!("{}\n... ({} bytes truncated)", &output[..1024], output.len() - 1024)
} else {
output.clone()
};
if ok {
state.log_bus.push("info", "pxeforge::terminal", mirror);
} else {
state.log_bus.push("warn", "pxeforge::terminal", mirror);
}
(StatusCode::OK, Json(json!({ "output": output, "ok": ok })))
}
async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
let head = argv[0].as_str();
let tail = &argv[1..];
match head {
"help" | "?" => Ok(HELP_TEXT.to_string()),
"version" => Ok(format!("pxeforge {}", env!("CARGO_PKG_VERSION"))),
"uptime" => Ok(uptime_string(state)),
"status" => Ok(status_text(state)),
"isos" | "images" => Ok(isos_text(state)),
"clients" => Ok(clients_text(state)),
"gate" => gate_command(state, tail).await,
"nfs" => nfs_command(state, tail).await,
"smb" => smb_command(state, tail).await,
"log" => log_command(state, tail),
"whoami" => Ok("operator".to_string()),
"echo" => Ok(tail.join(" ")),
"clear" => Ok("\x0c".to_string()), // form feed — frontend clears panel
other => Err(format!(
"unknown command: {other}\ntype 'help' for the list"
)),
}
}
// ── status / lists ─────────────────────────────────────────────────────
fn status_text(s: &AppState) -> String {
let isos = s.iso_store.list();
let clients = s.clients.list();
let gates = s.gates.list();
let smb = s.smb.as_ref().map(|m| m.snapshot());
let nfs = s.nfs.list();
let nfs_active = nfs.iter().filter(|m| m.mounted).count();
format!(
"PXEForge {ver}\n\
base url: {base}\n\
interface: {nic}\n\
uptime: {up}\n\
isos: {n_isos} (local: {n_local}, nfs: {n_nfs})\n\
clients: {n_clients}\n\
gates: {n_gates}\n\
smb: {smb}\n\
nfs mounts: {n_total} configured ({n_active} active)\n",
ver = env!("CARGO_PKG_VERSION"),
base = s.public_base_url,
nic = if s.nic_name.is_empty() { "?" } else { s.nic_name.as_str() },
up = uptime_string(s),
n_isos = isos.len(),
n_local = isos.iter().filter(|i| matches!(i.source, pxeforge_iso_store::IsoSource::Local)).count(),
n_nfs = isos.iter().filter(|i| !matches!(i.source, pxeforge_iso_store::IsoSource::Local)).count(),
n_clients = clients.len(),
n_gates = gates.len(),
smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")),
n_total = nfs.len(),
n_active = nfs_active,
)
}
fn isos_text(s: &AppState) -> String {
let isos = s.iso_store.list();
if isos.is_empty() {
return "(no isos)".into();
}
let mut out = String::new();
let _ = writeln!(
out,
"{:<32} {:<10} {:<10} {:<8}",
"ID", "FAMILY", "SIZE", "SOURCE"
);
for i in isos {
let src = match i.source {
pxeforge_iso_store::IsoSource::Local => "local".to_string(),
pxeforge_iso_store::IsoSource::Nfs { mount_id, .. } => format!("nfs:{mount_id}"),
};
let _ = writeln!(
out,
"{:<32} {:<10} {:<10} {:<8}",
truncate(&i.id, 32),
format!("{:?}", i.introspection.family),
human_bytes(i.size_bytes),
src,
);
}
out
}
fn clients_text(s: &AppState) -> String {
let clients = s.clients.list();
if clients.is_empty() {
return "(no clients yet)".into();
}
let mut out = String::new();
let _ = writeln!(
out,
"{:<19} {:<16} {:<8} {}",
"MAC", "IP", "EVENTS", "LAST SEEN"
);
for c in clients {
let ip = c.last_ip.map_or_else(|| "-".into(), |i| i.to_string());
let _ = writeln!(
out,
"{:<19} {:<16} {:<8} {}",
c.mac,
ip,
c.events.len(),
c.last_seen
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default()
);
}
out
}
// ── gate ───────────────────────────────────────────────────────────────
// `async` for symmetry with the other dispatch helpers — gate operations
// are sync today but might grow to await on a database in a future phase.
#[allow(clippy::unused_async)]
async fn gate_command(s: &AppState, args: &[String]) -> Result<String, String> {
match args.first().map(String::as_str) {
None | Some("list") => {
let gs = s.gates.list();
if gs.is_empty() {
return Ok("(no gates)".into());
}
let mut out = String::new();
for g in gs {
let _ = writeln!(
out,
"#{:<3} {:<19} {:<16} target={}",
g.position,
g.mac,
g.id,
g.assigned_target.unwrap_or_else(|| "-".into())
);
}
Ok(out)
}
Some("assign-all") => {
let target = args.get(1).ok_or_else(|| {
"usage: gate assign-all <iso_boot_entry_id>".to_string()
})?;
let found = s
.iso_store
.list()
.into_iter()
.any(|i| i.boot_entries.iter().any(|e| &e.id == target));
if !found {
return Err(format!("no such boot entry: {target}"));
}
let ids: Vec<_> = s.gates.list().into_iter().map(|g| g.id).collect();
let n = s.gates.assign(&ids, target);
Ok(format!("assigned {n} gates -> {target}"))
}
Some("assign") => {
let gate_id = args
.get(1)
.ok_or_else(|| "usage: gate assign <gate_id> <iso_boot_entry_id>".to_string())?;
let target = args
.get(2)
.ok_or_else(|| "usage: gate assign <gate_id> <iso_boot_entry_id>".to_string())?;
let n = s.gates.assign(std::slice::from_ref(gate_id), target);
if n == 0 {
return Err(format!("no such gate: {gate_id}"));
}
Ok(format!("assigned 1 gate -> {target}"))
}
Some("release") => {
let gate_id = args.get(1).ok_or_else(|| "usage: gate release <gate_id>".to_string())?;
match s.gates.release(gate_id) {
Some(_) => Ok(format!("released {gate_id}")),
None => Err(format!("no such gate: {gate_id}")),
}
}
Some(other) => Err(format!(
"unknown gate subcommand: {other}\ntry: gate [list|assign-all|assign|release]"
)),
}
}
// ── nfs ────────────────────────────────────────────────────────────────
async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
match args.first().map(String::as_str) {
None | Some("list") => {
let mounts = s.nfs.list();
if mounts.is_empty() {
return Ok("(no NFS mounts configured)".into());
}
let mut out = String::new();
let _ = writeln!(
out,
"{:<24} {:<6} {:<7} {:<6} {}",
"ID", "VER", "STATUS", "ISOS", "TARGET"
);
for m in mounts {
let status = if m.mounted { "ok" } else { "down" };
let _ = writeln!(
out,
"{:<24} {:<6} {:<7} {:<6} {}:{}",
truncate(&m.id, 24),
match m.version {
pxeforge_iso_store::NfsVersion::V3 => "v3",
pxeforge_iso_store::NfsVersion::V41 => "v4.1",
},
status,
m.iso_count,
m.server,
m.export,
);
if let Some(e) = m.last_error {
let _ = writeln!(out, " error: {e}");
}
}
Ok(out)
}
Some("mount") => {
// nfs mount <server>:<export> [v3|v41] [ro|rw]
let target = args
.get(1)
.ok_or_else(|| "usage: nfs mount <server>:<export> [v3|v41] [ro|rw]".to_string())?;
let (server, export) = target
.split_once(':')
.ok_or_else(|| "target must be 'server:/export'".to_string())?;
let version = match args.get(2).map(String::as_str) {
Some("v3") => pxeforge_iso_store::NfsVersion::V3,
Some("v41") | None => pxeforge_iso_store::NfsVersion::V41,
Some(other) => return Err(format!("unknown nfs version: {other} (expect v3 or v41)")),
};
let read_only = !matches!(args.get(3).map(String::as_str), Some("rw"));
let req = pxeforge_iso_store::NfsAddRequest {
server: server.to_string(),
export: export.to_string(),
version,
read_only,
};
match s.nfs.add(req).await {
Ok(m) => Ok(format!("mounted {} ({} isos)", m.id, m.iso_count)),
Err(e) => Err(format!("mount failed: {e}")),
}
}
Some("unmount") => {
let id = args.get(1).ok_or_else(|| "usage: nfs unmount <id>".to_string())?;
match s.nfs.remove(id).await {
Ok(()) => Ok(format!("unmounted {id}")),
Err(e) => Err(format!("unmount failed: {e}")),
}
}
Some("scan") => {
let id = args.get(1).ok_or_else(|| "usage: nfs scan <id>".to_string())?;
match s.nfs.rescan(id).await {
Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
Err(e) => Err(format!("scan failed: {e}")),
}
}
Some(other) => Err(format!(
"unknown nfs subcommand: {other}\ntry: nfs [list|mount|unmount|scan]"
)),
}
}
// ── smb ────────────────────────────────────────────────────────────────
#[allow(clippy::unused_async)]
async fn smb_command(s: &AppState, args: &[String]) -> Result<String, String> {
let smb = s
.smb
.as_ref()
.ok_or_else(|| "SMB manager not configured (Windows support disabled)".to_string())?;
match args.first().map(String::as_str) {
None | Some("status") => Ok(format!("{:#?}", smb.snapshot())),
Some("start") => {
// start/reconcile return the new SmbState — there's no
// separate Result type. The state itself indicates success
// or failure via its variant.
let st = smb.start();
Ok(format!("smbd start requested -> {st:?}"))
}
Some("stop") => {
smb.stop();
Ok("smbd stop requested".into())
}
Some("reload") => {
let st = smb.reconcile();
Ok(format!("smbd reload (SIGHUP) sent -> {st:?}"))
}
Some(other) => Err(format!(
"unknown smb subcommand: {other}\ntry: smb [status|start|stop|reload]"
)),
}
}
// ── log ────────────────────────────────────────────────────────────────
fn log_command(s: &AppState, args: &[String]) -> Result<String, String> {
match args.first().map(String::as_str) {
Some("clear") => {
s.log_bus.clear();
Ok("log buffer cleared".into())
}
Some("tail") => {
let n: usize = args
.get(1)
.and_then(|v| v.parse().ok())
.unwrap_or(20);
let lines = s.log_bus.recent();
let start = lines.len().saturating_sub(n);
let mut out = String::new();
for l in &lines[start..] {
let _ = writeln!(out, "{}", l.render());
}
if out.is_empty() {
Ok("(empty)".into())
} else {
Ok(out)
}
}
_ => Err("usage: log [clear|tail [n]]".into()),
}
}
// ── helpers ────────────────────────────────────────────────────────────
fn uptime_string(s: &AppState) -> String {
let now = time::OffsetDateTime::now_utc();
let secs = (now - s.started_at).whole_seconds().max(0);
let h = secs / 3600;
let m = (secs % 3600) / 60;
let s = secs % 60;
format!("{h}h {m}m {s}s")
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}", &s[..max.saturating_sub(1)])
}
}
fn human_bytes(n: u64) -> String {
const U: &[&str] = &["B", "KB", "MB", "GB", "TB"];
// Loss-of-precision past 2^52 is academic for ISO file sizes — even
// a 4 PiB file still rounds to the right unit.
#[allow(clippy::cast_precision_loss)]
let mut x = n as f64;
let mut i = 0;
while x >= 1024.0 && i < U.len() - 1 {
x /= 1024.0;
i += 1;
}
if i == 0 || x >= 10.0 {
format!("{:.0} {}", x, U[i])
} else {
format!("{:.1} {}", x, U[i])
}
}
/// Tiny shell-like splitter — splits on whitespace, honoring `'…'` and
/// `"…"` quoted segments. We deliberately don't expand `$VAR` or any
/// other shell metacharacters; this is a parser for our own command
/// vocabulary, not a real shell.
pub fn shell_split(input: &str) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
let mut in_single = false;
let mut in_double = false;
for ch in input.chars() {
match ch {
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
c if c.is_whitespace() && !in_single && !in_double => {
if !current.is_empty() {
out.push(std::mem::take(&mut current));
}
}
c => current.push(c),
}
}
if !current.is_empty() {
out.push(current);
}
out
}
const HELP_TEXT: &str = "\
PXEForge terminal — available commands:
help show this help
version print server version
status high-level server status
uptime time since startup
isos list registered ISOs
clients list PXE clients seen this session
gate list list gated-deployment queue
gate assign <gate_id> <target> assign one gate to a boot entry
gate assign-all <target> assign every waiting gate
gate release <gate_id> release one gate
nfs list list NFS mounts
nfs mount <s>:<e> [v3|v41] [ro|rw] add and mount an NFS share
nfs unmount <id> unmount and forget a share
nfs scan <id> re-scan a share for new ISOs
smb status SMB (Samba) state
smb start | stop | reload control smbd
log clear drop the in-memory log ring buffer
log tail [n] show the last n buffered lines (default 20)
clear clear the terminal pane
Tab to autocomplete is not implemented (sorry).\n";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_split_basic() {
assert_eq!(shell_split(""), Vec::<String>::new());
assert_eq!(shell_split("nfs list"), vec!["nfs", "list"]);
assert_eq!(
shell_split("nfs mount 10.0.0.5:/srv v41 ro"),
vec!["nfs", "mount", "10.0.0.5:/srv", "v41", "ro"]
);
}
#[test]
fn shell_split_quoted() {
assert_eq!(
shell_split("echo 'hello world' done"),
vec!["echo", "hello world", "done"]
);
assert_eq!(
shell_split(r#"echo "double quotes" 'and singles'"#),
vec!["echo", "double quotes", "and singles"]
);
}
#[test]
fn human_bytes_units() {
assert_eq!(human_bytes(0), "0 B");
assert_eq!(human_bytes(1023), "1023 B");
assert_eq!(human_bytes(1024), "1.0 KB");
assert_eq!(human_bytes(2 * 1024 * 1024), "2.0 MB");
assert_eq!(human_bytes(5 * 1024u64.pow(3)), "5.0 GB");
}
#[test]
fn truncate_keeps_short() {
assert_eq!(truncate("hi", 10), "hi");
assert_eq!(truncate("longerthanfive", 5), "long…");
}
}