Compare commits

...
2 Commits
Author SHA1 Message Date
Miles Ward 115ba779da Name update 2026-05-21 02:13:08 -04:00
Miles Ward 91848e02e3 v0.3.1: per-ISO boot password gate
Operators can now lock individual ISOs behind a password set in the
WebUI. Picking a locked image at the PXE menu prompts the operator on
the client console; the boot script is only released after a correct
match. The plaintext never leaves the request — server stores bcrypt
hashes, scripts never echo the candidate.

## Backend

- New optional `password_hash: Option<String>` on `IsoMeta`. Skipped
  during serialize when None, so existing meta.json files don't grow
  a noisy `null` field.
- `IsoStore::set_password(id, Some("pw"))` hashes via bcrypt
  `DEFAULT_COST` (10 — fast enough for an interactive iPXE prompt,
  expensive enough to be hostile to brute force on a leaked
  meta.json). `set_password(id, None)` and `set_password(id, Some(""))`
  both clear.
- `IsoStore::verify_password` returns Ok(true) when no password is
  set, so the gate stays open for the common case.
- `IsoMeta::is_password_protected()` predicate the HTTP layer + UI
  share.
- NFS-sourced ISOs persist their hash in memory only — the share is
  the source of truth for those, and it doesn't carry hash sidecars.

## HTTP API

- `PUT /api/isos/:id/password` body `{ "password": "..." }` to set,
  `{ "password": null }` (or empty string) to clear.
- `DELETE /api/isos/:id/password` for the explicit clear.
- Both 204 on success, 404 for unknown ids.
- `/boot/<entry>.ipxe` now intercepts:
  - no `?token=`        -> render password-prompt script
  - `?token=<wrong>`    -> render auth-fail script (sleeps 2s, chains
                           back to the entry which re-prompts)
  - `?token=<correct>`  -> render the real boot script
  - ISO without password ignores token entirely (per-MAC bookmarks
    still work without changes).

## iPXE prompt

`render_password_prompt`:
- `set password ` then `read --secret password` — accepts input
  without echoing.
- Empty input chains back to the main menu (lets the operator back
  out of a misclick).
- Submit chains `?token=${password:uristring}`. The `:uristring`
  modifier URL-encodes the value, so passwords with `&`, `?`, `=`,
  spaces, etc. survive transport.

`render_password_failed`:
- Single line saying so + 2s sleep, then re-chains the entry.
- Server-side WARN log records the entry id only, never the
  candidate value (verified in smoke test).

## UI

Storage tab's image table grows an `Auth` column showing
`protected` / `open`, plus a 🔒 next to the filename when locked.
Per-row "Set password" / "Password ✎" button toggles an inline
editor in the next table row containing:
- a "Password protect this image" checkbox
- a `<input type=password autocomplete=new-password>` (hidden when
  the checkbox is off)
- a Save button

Save calls PUT or DELETE on `/api/isos/:id/password` based on the
checkbox state and clears the input field before re-rendering, so
the plaintext doesn't sit in the DOM longer than needed.

## Menu indicator

`render_family_menu` adds a `*` prefix immediately before the size
box on protected entries — ASCII only because some firmware menu
consoles mangle non-ASCII glyphs. Looks like:

  item --key 1 win11_test-winpe *[ 5234 MB] Windows 11 Test ISO

## Tests

74 passing across the workspace (was 66 in v0.3.0):
- 3 new store unit tests (bcrypt round-trip, unknown-id error,
  meta.json persistence across restart)
- 2 new ipxe_script unit tests (prompt/auth-fail invariants:
  read --secret, uristring, no candidate echo)
- 3 new HTTP integration tests (full gate flow upload-set-prompt-
  fail-success-clear, null/empty bodies, 404 on unknown id)

cargo clippy --workspace --all-targets clean.

Local smoke verified upload + lock + prompt + auth-fail + correct +
menu indicator + log scrub on a real release binary.

## Operational notes

- HTTP, not HTTPS — token rides in the query string. Acceptable on
  a trusted boot VLAN; do NOT expose OpenPXE to untrusted networks
  with this feature relied on for security. Reverse-proxy in front
  of OpenPXE will end up with the token in access logs.
- bcrypt cost is `DEFAULT_COST` (10). One verify takes ~50ms on
  modern x86, which is the worst-case latency added to a correct
  boot. Tunable via the bcrypt crate if needed.
2026-05-06 22:35:11 -04:00
38 changed files with 1907 additions and 513 deletions
+2 -1
View File
@@ -12,7 +12,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.3.0" version = "0.3.2"
edition = "2021" edition = "2021"
rust-version = "1.80" rust-version = "1.80"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
@@ -53,6 +53,7 @@ uuid = { version = "1.10", features = ["v4", "serde"] }
time = { version = "0.3", features = ["serde", "serde-human-readable", "formatting", "macros"] } time = { version = "0.3", features = ["serde", "serde-human-readable", "formatting", "macros"] }
sha2 = "0.10" sha2 = "0.10"
hex = "0.4" hex = "0.4"
bcrypt = "0.15"
once_cell = "1.19" once_cell = "1.19"
parking_lot = "0.12" parking_lot = "0.12"
rust-embed = { version = "8.5", features = ["include-exclude"] } rust-embed = { version = "8.5", features = ["include-exclude"] }
+17 -16
View File
@@ -5,12 +5,12 @@ Container-native PXE boot server. A Rust reimplementation of
for Docker/OCI and OpenShift. Upload `.iso` files via the web UI; network for Docker/OCI and OpenShift. Upload `.iso` files via the web UI; network
clients PXE-boot them. clients PXE-boot them.
> **Status:** v0.2.0 / pre-beta. Phases 15 complete: full PXE stack, > **Status:** v0.3.2 / pre-beta. Phases 15 complete: full PXE stack,
> Queued Deployment queue, NFS-share ISO sources, live tracing log + an > Queued Deployment queue, NFS-share ISO sources, live tracing log + an
> operator terminal, per-MAC host bindings (Tinkerbell-style), > operator terminal, per-MAC host bindings (Tinkerbell-style),
> Prometheus `/metrics`, light/dark theme toggle, animated anvil > Prometheus `/metrics`, light/dark theme toggle, animated OpenPXE
> imaging-progress widget. **66 tests passing**, clippy clean. Ready > imaging-progress widget, and per-ISO boot passwords. The test suite and
> for real-hardware validation. > clippy are part of the release checklist. Ready for real-hardware validation.
## Design non-negotiables ## Design non-negotiables
@@ -41,13 +41,13 @@ clients PXE-boot them.
Tools > Utilities / OpenPXE Shell / Network Card Info Tools > Utilities / OpenPXE Shell / Network Card Info
Queued Deployment Queued Deployment
``` ```
6. **Queued Deployment queue** — the "horse race" launch flow. A client that 6. **Queued Deployment queue** — the coordinated launch flow. A client that
selects *Queued Deployment* gets a numbered position and waits. The selects *Queued Deployment* gets a numbered position and waits. The
operator picks an ISO in the web UI and fires it to every waiting operator picks an ISO in the web UI and fires it to every waiting
client simultaneously. client simultaneously.
7. **Web UI** (Netbox-style): sidebar nav (Dashboard / Network / Forge 7. **Web UI** (Netbox-style): sidebar nav (Dashboard / Network / Queue /
Gate / Storage / Hosts / Terminal / About), light + dark themes Storage / Hosts / Terminal / About), light + dark themes
(toggle top-right or press `T`), animated anvil "forge progress" (toggle top-right or press `T`), animated OpenPXE progress
widget when devices are imaging. All assets served from the binary — widget when devices are imaging. All assets served from the binary —
no external requests. no external requests.
8. **Per-MAC host bindings.** Pin a MAC to a boot target and the client 8. **Per-MAC host bindings.** Pin a MAC to a boot target and the client
@@ -81,7 +81,7 @@ skip TFTP and respond with an HTTP URL.
./scripts/fetch-ipxe.sh ./scripts/fetch-ipxe.sh
# 2. Build the container image (~3 min first time). # 2. Build the container image (~3 min first time).
docker buildx build -f deploy/docker/Dockerfile -t openpxe:0.1.0 --load . docker buildx build -f deploy/docker/Dockerfile -t openpxe:0.3.2 --load .
# 3. Run it on the box plugged into your PXE network. Set PUBLIC_IP to # 3. Run it on the box plugged into your PXE network. Set PUBLIC_IP to
# this host's LAN address so advertised iPXE URLs are reachable. # this host's LAN address so advertised iPXE URLs are reachable.
@@ -91,7 +91,7 @@ docker run -d --name openpxe \
-e OPENPXE_DHCP_MODE=proxy \ -e OPENPXE_DHCP_MODE=proxy \
-v $PWD/data/isos:/var/lib/openpxe/isos \ -v $PWD/data/isos:/var/lib/openpxe/isos \
-v $PWD/data/work:/var/lib/openpxe/work \ -v $PWD/data/work:/var/lib/openpxe/work \
openpxe:0.1.0 openpxe:0.3.2
# 4. Open the UI and drop an ISO in. # 4. Open the UI and drop an ISO in.
open http://10.0.0.5 open http://10.0.0.5
@@ -122,7 +122,7 @@ docker buildx create --name openpxe-multi --driver docker-container --use
# Build + push both linux/amd64 and linux/arm64 under one tag. # Build + push both linux/amd64 and linux/arm64 under one tag.
docker buildx build --builder openpxe-multi \ docker buildx build --builder openpxe-multi \
--platform linux/amd64,linux/arm64 \ --platform linux/amd64,linux/arm64 \
-t ghcr.io/YOUR-ORG/openpxe:0.1.0 \ -t ghcr.io/YOUR-ORG/openpxe:0.3.2 \
--push \ --push \
-f deploy/docker/Dockerfile . -f deploy/docker/Dockerfile .
``` ```
@@ -155,10 +155,10 @@ docker run --rm \
-v /my/iso-library:/seed:ro \ -v /my/iso-library:/seed:ro \
-v openpxe-data:/var/lib/openpxe/isos \ -v openpxe-data:/var/lib/openpxe/isos \
-e OPENPXE_PUBLIC_IP=10.0.0.5 \ -e OPENPXE_PUBLIC_IP=10.0.0.5 \
openpxe:0.1.0 seed --from /seed openpxe:0.3.2 seed --from /seed
# Dry run first to see what would be imported: # Dry run first to see what would be imported:
docker run --rm -v /my/iso-library:/seed:ro openpxe:0.1.0 seed --from /seed --dry-run docker run --rm -v /my/iso-library:/seed:ro openpxe:0.3.2 seed --from /seed --dry-run
``` ```
### Environment overrides ### Environment overrides
@@ -273,7 +273,7 @@ operational constraints inherited from the design:
## Queued Deployment ## Queued Deployment
The "horse race" launch flow, end to end: The coordinated launch flow, end to end:
1. A client boots and picks **Queued Deployment** in the PXE menu (or falls 1. A client boots and picks **Queued Deployment** in the PXE menu (or falls
through on timeout with the default `timeout_action`). through on timeout with the default `timeout_action`).
@@ -285,8 +285,9 @@ The "horse race" launch flow, end to end:
The server broadcasts the assignment to every queued client via a The server broadcasts the assignment to every queued client via a
`tokio::sync::Notify`; each client's next poll returns the boot script `tokio::sync::Notify`; each client's next poll returns the boot script
for the chosen image. for the chosen image.
5. Every client chains the same image at effectively the same moment — the 5. Every client chains the same image at effectively the same moment. The
queue releases and the horses run together. queue stays visible until the operator releases entries, which keeps a
useful audit trail during hardware testing.
No user-facing iPXE anywhere in this flow. The client only ever runs No user-facing iPXE anywhere in this flow. The client only ever runs
scripts we generate; the operator only interacts with the web UI. scripts we generate; the operator only interacts with the web UI.
+4 -1
View File
@@ -126,7 +126,10 @@ mod tests {
fn bootfile_names_stable() { fn bootfile_names_stable() {
assert_eq!(ClientArch::LegacyX86.ipxe_bootfile(), Some("undionly.kpxe")); assert_eq!(ClientArch::LegacyX86.ipxe_bootfile(), Some("undionly.kpxe"));
assert_eq!(ClientArch::X64Uefi.ipxe_bootfile(), Some("snponly.efi")); assert_eq!(ClientArch::X64Uefi.ipxe_bootfile(), Some("snponly.efi"));
assert_eq!(ClientArch::Arm64Uefi.ipxe_bootfile(), Some("snponly-arm64.efi")); assert_eq!(
ClientArch::Arm64Uefi.ipxe_bootfile(),
Some("snponly-arm64.efi")
);
assert_eq!(ClientArch::Unknown(0xFFFF).ipxe_bootfile(), None); assert_eq!(ClientArch::Unknown(0xFFFF).ipxe_bootfile(), None);
} }
+18 -12
View File
@@ -56,19 +56,25 @@ impl ClientRegistry {
) { ) {
let mut guard = self.inner.write(); let mut guard = self.inner.write();
let now = OffsetDateTime::now_utc(); let now = OffsetDateTime::now_utc();
let entry = guard.entry(mac.to_string()).or_insert_with(|| ClientSnapshot { let entry = guard
mac: mac.to_string(), .entry(mac.to_string())
last_ip: ip, .or_insert_with(|| ClientSnapshot {
arch, mac: mac.to_string(),
hostname: None, last_ip: ip,
first_seen: now, arch,
last_seen: now, hostname: None,
events: Vec::new(), first_seen: now,
selected_target: None, last_seen: now,
}); events: Vec::new(),
selected_target: None,
});
entry.last_seen = now; entry.last_seen = now;
if ip.is_some() { entry.last_ip = ip; } if ip.is_some() {
if arch.is_some() { entry.arch = arch; } entry.last_ip = ip;
}
if arch.is_some() {
entry.arch = arch;
}
entry.events.push((now, event)); entry.events.push((now, event));
// Cap event history per client to keep memory bounded. // Cap event history per client to keep memory bounded.
const MAX_EVENTS: usize = 64; const MAX_EVENTS: usize = 64;
+13 -5
View File
@@ -68,7 +68,7 @@ pub struct Paths {
pub work_dir: PathBuf, pub work_dir: PathBuf,
/// Directory containing bundled iPXE binaries (undionly.kpxe, snponly.efi, ...). /// Directory containing bundled iPXE binaries (undionly.kpxe, snponly.efi, ...).
pub ipxe_dir: PathBuf, pub ipxe_dir: PathBuf,
/// Path to the wimboot binary for Windows ISOs (optional — feature-gated). /// Path to the wimboot binary for Windows ISOs (optional — feature-controlled).
pub wimboot_path: Option<PathBuf>, pub wimboot_path: Option<PathBuf>,
/// Directory under which Windows ISOs are extracted and served via SMB. /// Directory under which Windows ISOs are extracted and served via SMB.
/// Only used when `settings.windows_enabled = true`. Defaults to /// Only used when `settings.windows_enabled = true`. Defaults to
@@ -128,16 +128,24 @@ impl Config {
/// Call this after loading the TOML file so env takes precedence. /// Call this after loading the TOML file so env takes precedence.
pub fn apply_env(&mut self) { pub fn apply_env(&mut self) {
if let Ok(v) = std::env::var("OPENPXE_HTTP_PORT") { if let Ok(v) = std::env::var("OPENPXE_HTTP_PORT") {
if let Ok(p) = v.parse() { self.server.http_port = p; } if let Ok(p) = v.parse() {
self.server.http_port = p;
}
} }
if let Ok(v) = std::env::var("OPENPXE_TFTP_PORT") { if let Ok(v) = std::env::var("OPENPXE_TFTP_PORT") {
if let Ok(p) = v.parse() { self.server.tftp_port = p; } if let Ok(p) = v.parse() {
self.server.tftp_port = p;
}
} }
if let Ok(v) = std::env::var("OPENPXE_DHCP_PORT") { if let Ok(v) = std::env::var("OPENPXE_DHCP_PORT") {
if let Ok(p) = v.parse() { self.network.dhcp_port = p; } if let Ok(p) = v.parse() {
self.network.dhcp_port = p;
}
} }
if let Ok(v) = std::env::var("OPENPXE_PUBLIC_IP") { if let Ok(v) = std::env::var("OPENPXE_PUBLIC_IP") {
if let Ok(ip) = v.parse() { self.server.public_ip = Some(ip); } if let Ok(ip) = v.parse() {
self.server.public_ip = Some(ip);
}
} }
if let Ok(v) = std::env::var("OPENPXE_DHCP_MODE") { if let Ok(v) = std::env::var("OPENPXE_DHCP_MODE") {
self.network.dhcp_mode = match v.to_ascii_lowercase().as_str() { self.network.dhcp_mode = match v.to_ascii_lowercase().as_str() {
+1 -1
View File
@@ -29,7 +29,7 @@ pub struct HostBinding {
/// don't have to worry about case. /// don't have to worry about case.
pub mac: String, pub mac: String,
/// Preferred boot entry id (matches a `BootEntry::id` in the iso /// Preferred boot entry id (matches a `BootEntry::id` in the iso
/// store) OR one of the reserved menu names: `_local`, `_gate`, /// store) OR one of the reserved menu names: `_local`, `_queue`,
/// `_tools_menu`. Empty string falls back to the menu. /// `_tools_menu`. Empty string falls back to the menu.
pub target: String, pub target: String,
/// Optional human-readable label shown in the UI (`"Tom's laptop"`, /// Optional human-readable label shown in the UI (`"Tom's laptop"`,
+2 -2
View File
@@ -6,18 +6,18 @@ pub mod arch;
pub mod client; pub mod client;
pub mod config; pub mod config;
pub mod error; pub mod error;
pub mod queue;
pub mod host_bindings; pub mod host_bindings;
pub mod log_bus; pub mod log_bus;
pub mod metrics; pub mod metrics;
pub mod queue;
pub mod settings; pub mod settings;
pub use arch::{ClientArch, FirmwareClass}; pub use arch::{ClientArch, FirmwareClass};
pub use client::{ClientEvent, ClientRegistry, ClientSnapshot}; pub use client::{ClientEvent, ClientRegistry, ClientSnapshot};
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig}; pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
pub use error::{Error, Result}; pub use error::{Error, Result};
pub use queue::{Gate, DeploymentQueue};
pub use host_bindings::{normalize_mac, HostBinding, HostBindings}; pub use host_bindings::{normalize_mac, HostBinding, HostBindings};
pub use log_bus::{LogBus, LogBusLayer, LogLine}; pub use log_bus::{LogBus, LogBusLayer, LogLine};
pub use metrics::{HttpRoute, Metrics}; pub use metrics::{HttpRoute, Metrics};
pub use queue::{DeploymentQueue, QueueEntry};
pub use settings::{Settings, SettingsStore, TimeoutAction}; pub use settings::{Settings, SettingsStore, TimeoutAction};
+62 -11
View File
@@ -87,7 +87,9 @@ impl Metrics {
} }
pub fn record_tftp_err(&self) { pub fn record_tftp_err(&self) {
self.inner.tftp_transfers_err.fetch_add(1, Ordering::Relaxed); self.inner
.tftp_transfers_err
.fetch_add(1, Ordering::Relaxed);
} }
// ── HTTP ─────────────────────────────────────────────────────────── // ── HTTP ───────────────────────────────────────────────────────────
@@ -181,7 +183,10 @@ impl Metrics {
"", "",
); );
let _ = writeln!(out, "# HELP openpxe_tftp_transfers_total TFTP transfers, by status."); let _ = writeln!(
out,
"# HELP openpxe_tftp_transfers_total TFTP transfers, by status."
);
let _ = writeln!(out, "# TYPE openpxe_tftp_transfers_total counter"); let _ = writeln!(out, "# TYPE openpxe_tftp_transfers_total counter");
let _ = writeln!( let _ = writeln!(
out, out,
@@ -201,7 +206,10 @@ impl Metrics {
"", "",
); );
let _ = writeln!(out, "# HELP openpxe_http_requests_total HTTP requests served, by route family."); let _ = writeln!(
out,
"# HELP openpxe_http_requests_total HTTP requests served, by route family."
);
let _ = writeln!(out, "# TYPE openpxe_http_requests_total counter"); let _ = writeln!(out, "# TYPE openpxe_http_requests_total counter");
for (label, counter) in [ for (label, counter) in [
("boot_script", &i.http_boot_script), ("boot_script", &i.http_boot_script),
@@ -218,14 +226,53 @@ impl Metrics {
} }
// Gauges. // Gauges.
write_gauge(&mut out, "openpxe_iso_count", "ISOs currently registered (local + NFS).", i.iso_count.load(Ordering::Relaxed), ""); write_gauge(
write_gauge(&mut out, "openpxe_client_count", "PXE clients seen this process lifetime.", i.client_count.load(Ordering::Relaxed), ""); &mut out,
write_gauge(&mut out, "openpxe_queue_count", "Clients currently waiting at the deployment queue.", i.queue_count.load(Ordering::Relaxed), ""); "openpxe_iso_count",
write_gauge(&mut out, "openpxe_queue_imaging", "Clients currently imaging (queue + assigned target).", i.queue_imaging.load(Ordering::Relaxed), ""); "ISOs currently registered (local + NFS).",
write_gauge(&mut out, "openpxe_nfs_mounts_active", "NFS shares currently mounted.", i.nfs_mounts_active.load(Ordering::Relaxed), ""); i.iso_count.load(Ordering::Relaxed),
write_gauge(&mut out, "openpxe_uptime_seconds", "Seconds since this OpenPXE instance started.", uptime_secs, ""); "",
);
write_gauge(
&mut out,
"openpxe_client_count",
"PXE clients seen this process lifetime.",
i.client_count.load(Ordering::Relaxed),
"",
);
write_gauge(
&mut out,
"openpxe_queue_count",
"Clients currently waiting at the deployment queue.",
i.queue_count.load(Ordering::Relaxed),
"",
);
write_gauge(
&mut out,
"openpxe_queue_imaging",
"Clients currently imaging (queue + assigned target).",
i.queue_imaging.load(Ordering::Relaxed),
"",
);
write_gauge(
&mut out,
"openpxe_nfs_mounts_active",
"NFS shares currently mounted.",
i.nfs_mounts_active.load(Ordering::Relaxed),
"",
);
write_gauge(
&mut out,
"openpxe_uptime_seconds",
"Seconds since this OpenPXE instance started.",
uptime_secs,
"",
);
let _ = writeln!(out, "# HELP openpxe_build_info Build metadata. Always 1; the version is in the label."); let _ = writeln!(
out,
"# HELP openpxe_build_info Build metadata. Always 1; the version is in the label."
);
let _ = writeln!(out, "# TYPE openpxe_build_info gauge"); let _ = writeln!(out, "# TYPE openpxe_build_info gauge");
let _ = writeln!(out, "openpxe_build_info{{version=\"{version}\"}} 1"); let _ = writeln!(out, "openpxe_build_info{{version=\"{version}\"}} 1");
@@ -259,7 +306,11 @@ mod tests {
m.record_http(HttpRoute::Api); m.record_http(HttpRoute::Api);
m.set_iso_count(3); m.set_iso_count(3);
let out = m.render("0.2.0", 42); let out = m.render("0.2.0", 42);
assert_eq!(out.matches("# TYPE openpxe_dhcp_replies_total counter").count(), 1); assert_eq!(
out.matches("# TYPE openpxe_dhcp_replies_total counter")
.count(),
1
);
assert_eq!(out.matches("# TYPE openpxe_iso_count gauge").count(), 1); assert_eq!(out.matches("# TYPE openpxe_iso_count gauge").count(), 1);
assert!(out.contains("openpxe_dhcp_replies_total{arch=\"uefi\"} 1")); assert!(out.contains("openpxe_dhcp_replies_total{arch=\"uefi\"} 1"));
assert!(out.contains("openpxe_dhcp_replies_total{arch=\"bios\"} 1")); assert!(out.contains("openpxe_dhcp_replies_total{arch=\"bios\"} 1"));
+28 -24
View File
@@ -1,16 +1,16 @@
//! Queued Deployment queue. //! Queued Deployment queue.
//! //!
//! When a client selects "Queued Deployment" at the PXE menu, iPXE POSTs to //! When a client selects "Queued Deployment" at the PXE menu, iPXE POSTs to
//! `/api/queue/join` and receives a gate position. It then enters a poll //! `/api/queue/join` and receives a queue position. It then enters a poll
//! loop hitting `/api/queue/poll/<id>`; the server holds the request open //! loop hitting `/api/queue/poll/<id>`; the server holds the request open
//! until either (a) the operator assigns an ISO from the WebUI, in which //! until either (a) the operator assigns an ISO from the WebUI, in which
//! case the poll returns an iPXE `chain` URL, or (b) the poll times out //! case the poll returns an iPXE `chain` URL, or (b) the poll times out
//! (iPXE's HTTP client has its own timeout), in which case iPXE re-POSTs. //! (iPXE's HTTP client has its own timeout), in which case iPXE re-POSTs.
//! //!
//! The WebUI shows the queue (`GET /api/gate`) and issues //! The WebUI shows the queue (`GET /api/queue`) and issues
//! `POST /api/queue/assign { iso_id, entry_ids: [...] }` to launch a single //! `POST /api/queue/assign { iso_id, entry_ids: [...] }` to launch a single
//! ISO across many gated clients at once. This is the "horse-race gate" //! ISO across many queued clients at once. Every waiting machine receives
//! UX the user asked for — every horse leaves the line simultaneously. //! the assignment without operator visits at the rack.
use parking_lot::RwLock; use parking_lot::RwLock;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -23,11 +23,11 @@ use uuid::Uuid;
use crate::ClientArch; use crate::ClientArch;
/// Per-gate state visible to the WebUI. /// Per-client queue state visible to the WebUI.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Gate { pub struct QueueEntry {
pub id: String, pub id: String,
/// 1-based race-gate position — position 1 is whoever got there first. /// 1-based queue position — position 1 is whoever got there first.
pub position: u32, pub position: u32,
pub mac: String, pub mac: String,
pub ip: Option<IpAddr>, pub ip: Option<IpAddr>,
@@ -55,8 +55,8 @@ struct QueueEntryInner {
} }
impl QueueEntryInner { impl QueueEntryInner {
fn snapshot(&self) -> Gate { fn snapshot(&self) -> QueueEntry {
Gate { QueueEntry {
id: self.id.clone(), id: self.id.clone(),
position: self.position, position: self.position,
mac: self.mac.clone(), mac: self.mac.clone(),
@@ -80,21 +80,25 @@ impl DeploymentQueue {
Arc::new(Self::default()) Arc::new(Self::default())
} }
/// Add a client to the gate. Returns the new `Gate` snapshot. If the /// Add a client to the queue. Returns the current queue snapshot. If the
/// MAC is already queued, the existing gate is returned unchanged — /// MAC is already queued, the existing entry is returned unchanged —
/// retrying iPXE clients don't duplicate their slot. /// retrying iPXE clients don't duplicate their slot.
pub fn join(&self, mac: &str, ip: Option<IpAddr>, arch: Option<ClientArch>) -> Gate { pub fn join(&self, mac: &str, ip: Option<IpAddr>, arch: Option<ClientArch>) -> QueueEntry {
let now = OffsetDateTime::now_utc(); let now = OffsetDateTime::now_utc();
let mut guard = self.inner.write(); let mut guard = self.inner.write();
if let Some(existing) = guard.values_mut().find(|g| g.mac == mac) { if let Some(existing) = guard.values_mut().find(|g| g.mac == mac) {
existing.last_poll_at = now; existing.last_poll_at = now;
if ip.is_some() { existing.ip = ip; } if ip.is_some() {
if arch.is_some() { existing.arch = arch; } existing.ip = ip;
}
if arch.is_some() {
existing.arch = arch;
}
return existing.snapshot(); return existing.snapshot();
} }
// Race position = max(position) + 1, or 1 if empty. // Queue position = max(position) + 1, or 1 if empty.
let next_pos = guard.values().map(|g| g.position).max().unwrap_or(0) + 1; let next_pos = guard.values().map(|g| g.position).max().unwrap_or(0) + 1;
let id = Uuid::new_v4().to_string(); let id = Uuid::new_v4().to_string();
let inner = QueueEntryInner { let inner = QueueEntryInner {
@@ -113,24 +117,24 @@ impl DeploymentQueue {
snap snap
} }
/// Look up the `Notify` primitive for a given gate id, for long-polling. /// Look up the `Notify` primitive for a given queue entry id, for long-polling.
#[must_use] #[must_use]
pub fn notifier(&self, entry_id: &str) -> Option<Arc<Notify>> { pub fn notifier(&self, entry_id: &str) -> Option<Arc<Notify>> {
self.inner.read().get(entry_id).map(|g| g.notify.clone()) self.inner.read().get(entry_id).map(|g| g.notify.clone())
} }
/// Update the last-poll timestamp (keeps the gate's "live" indicator /// Update the last-poll timestamp (keeps the queue's "live" indicator
/// fresh in the UI) and return the current snapshot. Returns None if /// fresh in the UI) and return the current snapshot. Returns None if
/// the gate was released/expired between requests. /// the entry was released/expired between requests.
pub fn touch(&self, entry_id: &str) -> Option<Gate> { pub fn touch(&self, entry_id: &str) -> Option<QueueEntry> {
let mut guard = self.inner.write(); let mut guard = self.inner.write();
let g = guard.get_mut(entry_id)?; let g = guard.get_mut(entry_id)?;
g.last_poll_at = OffsetDateTime::now_utc(); g.last_poll_at = OffsetDateTime::now_utc();
Some(g.snapshot()) Some(g.snapshot())
} }
/// Operator assigns an ISO entry (boot_entry id) to one or more gates. /// Operator assigns an ISO entry (boot_entry id) to one or more clients.
/// Returns the number of gates that were updated. Gates not in the /// Returns the number of queue entries that were updated. Entries not in the
/// queue are silently skipped. /// queue are silently skipped.
pub fn assign(&self, entry_ids: &[String], target: &str) -> usize { pub fn assign(&self, entry_ids: &[String], target: &str) -> usize {
let mut guard = self.inner.write(); let mut guard = self.inner.write();
@@ -145,9 +149,9 @@ impl DeploymentQueue {
updated updated
} }
/// Remove a gate and return its final snapshot. Called after the client /// Remove a queue entry and return its final snapshot. Called after the client
/// has successfully chained onto its assignment. /// has successfully chained onto its assignment.
pub fn release(&self, entry_id: &str) -> Option<Gate> { pub fn release(&self, entry_id: &str) -> Option<QueueEntry> {
let mut guard = self.inner.write(); let mut guard = self.inner.write();
let g = guard.remove(entry_id)?; let g = guard.remove(entry_id)?;
g.notify.notify_waiters(); g.notify.notify_waiters();
@@ -162,7 +166,7 @@ impl DeploymentQueue {
} }
#[must_use] #[must_use]
pub fn list(&self) -> Vec<Gate> { pub fn list(&self) -> Vec<QueueEntry> {
let guard = self.inner.read(); let guard = self.inner.read();
let mut v: Vec<_> = guard.values().map(QueueEntryInner::snapshot).collect(); let mut v: Vec<_> = guard.values().map(QueueEntryInner::snapshot).collect();
v.sort_by_key(|g| g.position); v.sort_by_key(|g| g.position);
+14 -8
View File
@@ -48,9 +48,9 @@ pub struct Settings {
pub default_local_hdd: bool, pub default_local_hdd: bool,
/// When a client hits the Queued Deployment item, how long (seconds) to /// When a client hits the Queued Deployment item, how long (seconds) to
/// hold it at the gate before giving up and falling back to the menu. /// hold it in queue before giving up and falling back to the menu.
/// 0 = forever. /// 0 = forever.
pub gate_wait_max_secs: u32, pub queue_wait_max_secs: u32,
/// Optional DNS server advertised on the Network tab. Purely /// Optional DNS server advertised on the Network tab. Purely
/// informational today — OpenPXE does not run a DNS server, but /// informational today — OpenPXE does not run a DNS server, but
@@ -67,11 +67,8 @@ pub enum TimeoutAction {
/// Chain the "Boot from Local HDD" entry. /// Chain the "Boot from Local HDD" entry.
LocalHdd, LocalHdd,
/// Put the client into the deployment queue, waiting for operator /// Put the client into the deployment queue, waiting for operator
/// assignment. The serde alias keeps v0.2.0 settings.json files /// assignment.
/// readable after the v0.3.0 rename — old `"gated_deployment"`
/// values deserialize transparently.
#[default] #[default]
#[serde(alias = "gated_deployment")]
QueuedDeployment, QueuedDeployment,
} }
@@ -84,7 +81,7 @@ impl Default for Settings {
smb_host_override: String::new(), smb_host_override: String::new(),
extra_kernel_args: String::new(), extra_kernel_args: String::new(),
default_local_hdd: true, default_local_hdd: true,
gate_wait_max_secs: 0, queue_wait_max_secs: 0,
dns_server: String::new(), dns_server: String::new(),
} }
} }
@@ -115,7 +112,10 @@ impl SettingsStore {
}, },
Err(_) => Settings::default(), Err(_) => Settings::default(),
}; };
Arc::new(Self { path, inner: RwLock::new(initial) }) Arc::new(Self {
path,
inner: RwLock::new(initial),
})
} }
#[must_use] #[must_use]
@@ -181,6 +181,12 @@ mod tests {
assert!(s.windows_enabled); assert!(s.windows_enabled);
} }
#[test]
fn settings_serialize_queue_naming() {
let text = serde_json::to_string(&Settings::default()).unwrap();
assert!(text.contains("queue_wait_max_secs"));
}
#[test] #[test]
fn corrupt_file_falls_back_to_default() { fn corrupt_file_falls_back_to_default() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
+14 -4
View File
@@ -56,11 +56,17 @@ pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
// it'll then do the same script-fetch the iPXE path does. // it'll then do the same script-fetch the iPXE path does.
let name = ctx.arch.ipxe_bootfile().unwrap_or("snponly.efi"); let name = ctx.arch.ipxe_bootfile().unwrap_or("snponly.efi");
BootDirective::HttpScript { BootDirective::HttpScript {
url: format!("{}/ipxe/{}", ctx.public_base_url.trim_end_matches('/'), name), url: format!(
"{}/ipxe/{}",
ctx.public_base_url.trim_end_matches('/'),
name
),
} }
} }
FirmwareClass::PxeClient => match ctx.arch.ipxe_bootfile() { FirmwareClass::PxeClient => match ctx.arch.ipxe_bootfile() {
Some(name) => BootDirective::TftpIpxe { filename: name.to_string() }, Some(name) => BootDirective::TftpIpxe {
filename: name.to_string(),
},
None => BootDirective::Ignore, None => BootDirective::Ignore,
}, },
FirmwareClass::Other => BootDirective::Ignore, FirmwareClass::Other => BootDirective::Ignore,
@@ -107,12 +113,16 @@ pub fn build_reply(ctx: &ReplyContext<'_>, directive: &BootDirective) -> Option<
match directive { match directive {
BootDirective::TftpIpxe { filename } => { BootDirective::TftpIpxe { filename } => {
opts.insert(DhcpOption::TFTPServerName(ctx.our_ip.to_string().into_bytes())); opts.insert(DhcpOption::TFTPServerName(
ctx.our_ip.to_string().into_bytes(),
));
opts.insert(DhcpOption::BootfileName(filename.as_bytes().to_vec())); opts.insert(DhcpOption::BootfileName(filename.as_bytes().to_vec()));
} }
BootDirective::HttpScript { url } => { BootDirective::HttpScript { url } => {
opts.insert(DhcpOption::BootfileName(url.as_bytes().to_vec())); opts.insert(DhcpOption::BootfileName(url.as_bytes().to_vec()));
opts.insert(DhcpOption::TFTPServerName(ctx.our_ip.to_string().into_bytes())); opts.insert(DhcpOption::TFTPServerName(
ctx.our_ip.to_string().into_bytes(),
));
} }
BootDirective::Ignore => return None, BootDirective::Ignore => return None,
} }
+34 -13
View File
@@ -4,9 +4,7 @@
use crate::reply::{build_reply, decide, BootDirective, ReplyContext}; use crate::reply::{build_reply, decide, BootDirective, ReplyContext};
use dhcproto::v4::{DhcpOption, Message, OptionCode}; use dhcproto::v4::{DhcpOption, Message, OptionCode};
use dhcproto::{Decodable, Decoder, Encodable, Encoder}; use dhcproto::{Decodable, Decoder, Encodable, Encoder};
use openpxe_core::{ use openpxe_core::{ClientArch, ClientEvent, ClientRegistry, FirmwareClass};
ClientArch, ClientEvent, ClientRegistry, FirmwareClass,
};
use socket2::{Domain, Protocol, Socket, Type}; use socket2::{Domain, Protocol, Socket, Type};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use std::sync::Arc; use std::sync::Arc;
@@ -86,11 +84,22 @@ impl DhcpProxyServer {
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let request = Message::decode(&mut Decoder::new(data))?; let request = Message::decode(&mut Decoder::new(data))?;
let vendor_class = request.opts().get(OptionCode::ClassIdentifier).and_then(|o| { let vendor_class = request
if let DhcpOption::ClassIdentifier(v) = o { Some(v.as_slice()) } else { None } .opts()
}); .get(OptionCode::ClassIdentifier)
.and_then(|o| {
if let DhcpOption::ClassIdentifier(v) = o {
Some(v.as_slice())
} else {
None
}
});
let user_class = request.opts().get(OptionCode::UserClass).and_then(|o| { let user_class = request.opts().get(OptionCode::UserClass).and_then(|o| {
if let DhcpOption::UserClass(v) = o { Some(v.as_slice()) } else { None } if let DhcpOption::UserClass(v) = o {
Some(v.as_slice())
} else {
None
}
}); });
let class = FirmwareClass::classify(vendor_class, user_class); let class = FirmwareClass::classify(vendor_class, user_class);
if matches!(class, FirmwareClass::Other) { if matches!(class, FirmwareClass::Other) {
@@ -135,7 +144,9 @@ impl DhcpProxyServer {
} }
self.metrics.record_dhcp_reply(arch.as_str()); self.metrics.record_dhcp_reply(arch.as_str());
let Some(reply) = build_reply(&ctx, &directive) else { return Ok(()); }; let Some(reply) = build_reply(&ctx, &directive) else {
return Ok(());
};
let mut out = Vec::with_capacity(512); let mut out = Vec::with_capacity(512);
reply.encode(&mut Encoder::new(&mut out))?; reply.encode(&mut Encoder::new(&mut out))?;
@@ -203,7 +214,10 @@ fn bind_udp(bind: IpAddr, port: u16, broadcast: bool) -> anyhow::Result<UdpSocke
fn format_mac(chaddr: &[u8]) -> String { fn format_mac(chaddr: &[u8]) -> String {
let take = chaddr.iter().take(6).copied().collect::<Vec<_>>(); let take = chaddr.iter().take(6).copied().collect::<Vec<_>>();
take.iter().map(|b| format!("{b:02x}")).collect::<Vec<_>>().join(":") take.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(":")
} }
/// Walk raw DHCP options looking for option 93 (Client System Architecture) /// Walk raw DHCP options looking for option 93 (Client System Architecture)
@@ -217,10 +231,17 @@ fn extract_raw_arch(packet: &[u8]) -> Option<u16> {
let mut i = 0; let mut i = 0;
while i < opts.len() { while i < opts.len() {
let code = opts[i]; let code = opts[i];
if code == 0xff { return None; } // END if code == 0xff {
if code == 0x00 { i += 1; continue; } // PAD return None;
} // END
if code == 0x00 {
i += 1;
continue;
} // PAD
i += 1; i += 1;
if i >= opts.len() { return None; } if i >= opts.len() {
return None;
}
let len = opts[i] as usize; let len = opts[i] as usize;
i += 1; i += 1;
if code == 93 && len >= 2 && i + 2 <= opts.len() { if code == 93 && len >= 2 && i + 2 <= opts.len() {
@@ -240,7 +261,7 @@ mod tests {
// Minimal BOOTP header + magic cookie + option 93 (arch)=0x0007 + END. // Minimal BOOTP header + magic cookie + option 93 (arch)=0x0007 + END.
let mut pkt = vec![0u8; 240]; let mut pkt = vec![0u8; 240];
pkt[236..240].copy_from_slice(&[99, 130, 83, 99]); // magic cookie pkt[236..240].copy_from_slice(&[99, 130, 83, 99]); // magic cookie
pkt.extend_from_slice(&[53, 1, 1]); // option 53 DHCPDISCOVER pkt.extend_from_slice(&[53, 1, 1]); // option 53 DHCPDISCOVER
pkt.extend_from_slice(&[93, 2, 0x00, 0x07]); pkt.extend_from_slice(&[93, 2, 0x00, 0x07]);
pkt.push(0xff); pkt.push(0xff);
assert_eq!(extract_raw_arch(&pkt), Some(0x0007)); assert_eq!(extract_raw_arch(&pkt), Some(0x0007));
+326 -92
View File
@@ -14,8 +14,8 @@
//! | `/api/*` | JSON/HTML API for the web UI | //! | `/api/*` | JSON/HTML API for the web UI |
use crate::ipxe_script::{ use crate::ipxe_script::{
render_entry, render_family_menu, render_queue_entry, render_local_hdd, render_entry, render_family_menu, render_local_hdd, render_menu, render_nic_info,
render_menu, render_nic_info, render_shell, render_tools_menu, render_util, render_queue_entry, render_shell, render_tools_menu, render_util,
}; };
use crate::iso_fs; use crate::iso_fs;
use crate::log_stream; use crate::log_stream;
@@ -61,6 +61,12 @@ pub fn build_router(state: AppState) -> Router {
// JSON API. // JSON API.
.route("/api/isos", get(api_list_isos).post(api_upload_iso)) .route("/api/isos", get(api_list_isos).post(api_upload_iso))
.route("/api/isos/:id", delete(api_delete_iso)) .route("/api/isos/:id", delete(api_delete_iso))
// Per-ISO password prompt. PUT body `{ "password": "..." }`
// sets, `{ "password": null }` (or DELETE) clears.
.route(
"/api/isos/:id/password",
axum::routing::put(api_set_iso_password).delete(api_clear_iso_password),
)
.route("/api/clients", get(api_list_clients)) .route("/api/clients", get(api_list_clients))
.route("/api/status", get(api_status)) .route("/api/status", get(api_status))
.route("/api/settings", get(api_get_settings).put(api_put_settings)) .route("/api/settings", get(api_get_settings).put(api_put_settings))
@@ -99,42 +105,67 @@ pub fn build_router(state: AppState) -> Router {
async fn index(State(state): State<AppState>) -> Response { async fn index(State(state): State<AppState>) -> Response {
let html = openpxe_webui::index_html(&state.public_base_url); let html = openpxe_webui::index_html(&state.public_base_url);
([(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"))], html) (
[(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
)],
html,
)
.into_response() .into_response()
} }
async fn ui_js() -> Response { async fn ui_js() -> Response {
( (
[(header::CONTENT_TYPE, HeaderValue::from_static("application/javascript"))], [(
header::CONTENT_TYPE,
HeaderValue::from_static("application/javascript"),
)],
openpxe_webui::app_js(), openpxe_webui::app_js(),
).into_response() )
.into_response()
} }
async fn ui_css() -> Response { async fn ui_css() -> Response {
( (
[(header::CONTENT_TYPE, HeaderValue::from_static("text/css"))], [(header::CONTENT_TYPE, HeaderValue::from_static("text/css"))],
openpxe_webui::app_css(), openpxe_webui::app_css(),
).into_response() )
.into_response()
} }
async fn ui_logo() -> Response { async fn ui_logo() -> Response {
( (
[(header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml"))], [(
header::CONTENT_TYPE,
HeaderValue::from_static("image/svg+xml"),
)],
openpxe_webui::logo_svg(), openpxe_webui::logo_svg(),
).into_response() )
.into_response()
} }
async fn ui_loader() -> Response { async fn ui_loader() -> Response {
( (
[(header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml"))], [(
header::CONTENT_TYPE,
HeaderValue::from_static("image/svg+xml"),
)],
openpxe_webui::loader_svg(), openpxe_webui::loader_svg(),
).into_response() )
.into_response()
} }
// ─── iPXE scripts ────────────────────────────────────────────────────────── // ─── iPXE scripts ──────────────────────────────────────────────────────────
fn text_plain(body: String) -> Response { fn text_plain(body: String) -> Response {
([(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8"))], body) (
[(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
)],
body,
)
.into_response() .into_response()
} }
@@ -142,11 +173,10 @@ fn text_plain(body: String) -> Response {
/// requesting client carries a `?mac=...` query param (iPXE's `${mac}` /// requesting client carries a `?mac=...` query param (iPXE's `${mac}`
/// substitution) and that MAC has a binding, we short-circuit straight /// substitution) and that MAC has a binding, we short-circuit straight
/// to the bound target instead of rendering the menu. /// to the bound target instead of rendering the menu.
async fn boot_top_menu( async fn boot_top_menu(State(state): State<AppState>, Query(p): Query<BootMenuParams>) -> Response {
State(state): State<AppState>, state
Query(p): Query<BootMenuParams>, .metrics
) -> Response { .record_http(openpxe_core::HttpRoute::BootScript);
state.metrics.record_http(openpxe_core::HttpRoute::BootScript);
let isos = state.iso_store.list(); let isos = state.iso_store.list();
let settings = state.settings.snapshot(); let settings = state.settings.snapshot();
let base = &state.public_base_url; let base = &state.public_base_url;
@@ -185,9 +215,19 @@ struct BootMenuParams {
mac: Option<String>, mac: Option<String>,
} }
#[derive(Debug, Deserialize)]
struct BootSubParams {
/// iPXE-supplied password token. Sent by the prompt script as
/// `?token=${password:uristring}` so special chars survive URL
/// encoding. Absent on the first request — that's how we know the
/// client hasn't been prompted yet.
token: Option<String>,
}
async fn boot_sub( async fn boot_sub(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(filename): AxumPath<String>, AxumPath(filename): AxumPath<String>,
Query(p): Query<BootSubParams>,
) -> Response { ) -> Response {
// `/boot/<name>.ipxe` where `<name>` is either one of our reserved // `/boot/<name>.ipxe` where `<name>` is either one of our reserved
// submenu names (prefixed `_`) or a boot entry id. // submenu names (prefixed `_`) or a boot entry id.
@@ -196,18 +236,70 @@ async fn boot_sub(
let settings = state.settings.snapshot(); let settings = state.settings.snapshot();
let base = &state.public_base_url; let base = &state.public_base_url;
let script = match name { let script = match name {
"_local" => render_local_hdd(base), "_local" => render_local_hdd(base),
"_linux_menu" => render_family_menu(&isos, base, false), "_linux_menu" => render_family_menu(&isos, base, false),
"_windows_menu" => render_family_menu(&isos, base, true), "_windows_menu" => render_family_menu(&isos, base, true),
"_tools_menu" => render_tools_menu(base), "_tools_menu" => render_tools_menu(base),
"_util" => render_util(base), "_util" => render_util(base),
"_shell" => render_shell(base), "_shell" => render_shell(base),
"_nic" => render_nic_info(base), "_nic" => render_nic_info(base),
"_queue" => render_queue_entry(base), "_queue" => render_queue_entry(base),
other => { other => {
for iso in &isos { for iso in &isos {
for entry in &iso.boot_entries { for entry in &iso.boot_entries {
if entry.id == other { if entry.id == other {
// Password prompt. If the ISO has a password set
// we block the actual boot script behind it:
// - no token -> render a prompt
// - wrong token -> render auth-fail
// - correct token -> serve the boot script
// ISO without a password ignores the token
// entirely, so per-MAC bookmarks stay simple.
if iso.is_password_protected() {
match p.token.as_deref() {
None | Some("") => {
return text_plain(crate::ipxe_script::render_password_prompt(
&entry.id,
&iso.filename,
base,
));
}
Some(token) => {
match state.iso_store.verify_password(&iso.id, token) {
Ok(true) => { /* fall through to render the entry */ }
Ok(false) => {
// Don't log the candidate — just the
// mac (when iPXE supplies one) and
// the entry id, so an operator can
// see brute-force attempts in the
// live log.
tracing::warn!(
target: "openpxe::http::boot",
entry = %other,
"wrong password supplied for protected boot entry"
);
return text_plain(
crate::ipxe_script::render_password_failed(
&entry.id, base,
),
);
}
Err(e) => {
tracing::error!(
target: "openpxe::http::boot",
entry = %other, error = %e,
"password verify failed unexpectedly"
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
"password check failed",
)
.into_response();
}
}
}
}
}
return text_plain(render_entry(entry, &settings, base)); return text_plain(render_entry(entry, &settings, base));
} }
} }
@@ -229,11 +321,15 @@ async fn ipxe_binary(AxumPath(name): AxumPath<String>) -> Response {
}; };
( (
[ [
(header::CONTENT_TYPE, HeaderValue::from_static("application/octet-stream")), (
header::CONTENT_TYPE,
HeaderValue::from_static("application/octet-stream"),
),
(header::CONTENT_LENGTH, HeaderValue::from(bytes.len())), (header::CONTENT_LENGTH, HeaderValue::from(bytes.len())),
], ],
bytes, bytes,
).into_response() )
.into_response()
} }
// ─── ISO streaming (raw + in-ISO) ───────────────────────────────────────── // ─── ISO streaming (raw + in-ISO) ─────────────────────────────────────────
@@ -263,7 +359,9 @@ async fn iso_file(
let p = iso_path.clone(); let p = iso_path.clone();
let in_path = format!("/{path}"); let in_path = format!("/{path}");
let loc = tokio::task::spawn_blocking(move || iso_fs::lookup(&p, &in_path)) let loc = tokio::task::spawn_blocking(move || iso_fs::lookup(&p, &in_path))
.await.ok().flatten(); .await
.ok()
.flatten();
let Some(loc) = loc else { let Some(loc) = loc else {
return (StatusCode::NOT_FOUND, "not found inside iso").into_response(); return (StatusCode::NOT_FOUND, "not found inside iso").into_response();
}; };
@@ -279,21 +377,43 @@ async fn stream_file_range(
) -> anyhow::Result<Response> { ) -> anyhow::Result<Response> {
let meta = tokio::fs::metadata(path).await?; let meta = tokio::fs::metadata(path).await?;
let total = meta.len(); let total = meta.len();
let (start, end, partial) = parse_range(range, total); if total == 0 {
return Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_LENGTH, 0)
.body(Body::empty())
.unwrap());
}
let Some((start, end, partial)) = parse_range(range, total) else {
return Ok(Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{total}"))
.body(Body::empty())
.unwrap());
};
let len = end - start + 1; let len = end - start + 1;
let mut file = tokio::fs::File::open(path).await?; let mut file = tokio::fs::File::open(path).await?;
file.seek(std::io::SeekFrom::Start(start)).await?; file.seek(std::io::SeekFrom::Start(start)).await?;
let reader = file.take(len); let reader = file.take(len);
let stream = tokio_util::io::ReaderStream::new(reader); let stream = tokio_util::io::ReaderStream::new(reader);
let body = Body::from_stream(stream); let body = Body::from_stream(stream);
let status = if partial { StatusCode::PARTIAL_CONTENT } else { StatusCode::OK }; let status = if partial {
StatusCode::PARTIAL_CONTENT
} else {
StatusCode::OK
};
let mut builder = Response::builder() let mut builder = Response::builder()
.status(status) .status(status)
.header(header::CONTENT_TYPE, "application/octet-stream") .header(header::CONTENT_TYPE, "application/octet-stream")
.header(header::ACCEPT_RANGES, "bytes") .header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_LENGTH, len); .header(header::CONTENT_LENGTH, len);
if partial { if partial {
builder = builder.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{total}")); builder = builder.header(
header::CONTENT_RANGE,
format!("bytes {start}-{end}/{total}"),
);
} }
Ok(builder.body(body).unwrap()) Ok(builder.body(body).unwrap())
} }
@@ -316,21 +436,40 @@ async fn stream_byte_range(
.unwrap()) .unwrap())
} }
fn parse_range(h: Option<&HeaderValue>, total: u64) -> (u64, u64, bool) { fn parse_range(h: Option<&HeaderValue>, total: u64) -> Option<(u64, u64, bool)> {
let Some(h) = h else { return (0, total.saturating_sub(1), false); }; let Some(h) = h else {
let Ok(s) = h.to_str() else { return (0, total.saturating_sub(1), false); }; return Some((0, total.saturating_sub(1), false));
let Some(spec) = s.strip_prefix("bytes=") else { return (0, total.saturating_sub(1), false); }; };
let Ok(s) = h.to_str() else {
return Some((0, total.saturating_sub(1), false));
};
let Some(spec) = s.strip_prefix("bytes=") else {
return Some((0, total.saturating_sub(1), false));
};
let spec = spec.split(',').next().unwrap_or("").trim(); let spec = spec.split(',').next().unwrap_or("").trim();
if let Some(suffix) = spec.strip_prefix('-') { if let Some(suffix) = spec.strip_prefix('-') {
if let Ok(n) = suffix.parse::<u64>() { if let Ok(n) = suffix.parse::<u64>() {
let n = n.min(total); let n = n.min(total);
return (total.saturating_sub(n), total.saturating_sub(1), true); return Some((total.saturating_sub(n), total.saturating_sub(1), true));
} }
} }
let mut parts = spec.splitn(2, '-'); let mut parts = spec.splitn(2, '-');
let start = parts.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0); let start = parts
let end = parts.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(total.saturating_sub(1)); .next()
(start, end.min(total.saturating_sub(1)), true) .and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let end = parts
.next()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(total.saturating_sub(1));
if start >= total {
return None;
}
let end = end.min(total.saturating_sub(1));
if start > end {
return None;
}
Some((start, end, true))
} }
// ─── ISO upload / list / delete ─────────────────────────────────────────── // ─── ISO upload / list / delete ───────────────────────────────────────────
@@ -349,12 +488,68 @@ async fn api_delete_iso(
} }
} }
async fn api_upload_iso( #[derive(Debug, Deserialize)]
struct SetPasswordBody {
/// Plaintext password. `null` or empty/whitespace clears the
/// password (same as a DELETE on this resource). The server hashes
/// with bcrypt before persisting; the plaintext is never stored.
password: Option<String>,
}
async fn api_set_iso_password(
State(state): State<AppState>, State(state): State<AppState>,
mut multipart: Multipart, AxumPath(id): AxumPath<String>,
Json(body): Json<SetPasswordBody>,
) -> Response { ) -> Response {
match state
.iso_store
.set_password(&id, body.password.as_deref())
.await
{
Ok(()) => {
let now_protected = state
.iso_store
.get(&id)
.is_some_and(|m| m.is_password_protected());
// We deliberately do not log the password value, only
// whether the ISO ended up protected.
tracing::info!(
target: "openpxe::http::iso",
iso = %id, protected = now_protected,
"iso password updated"
);
StatusCode::NO_CONTENT.into_response()
}
Err(openpxe_error_invalid)
if matches!(openpxe_error_invalid, openpxe_core::Error::Invalid(_)) =>
{
(StatusCode::NOT_FOUND, format!("{openpxe_error_invalid}")).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
}
async fn api_clear_iso_password(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Response {
match state.iso_store.set_password(&id, None).await {
Ok(()) => {
tracing::info!(
target: "openpxe::http::iso",
iso = %id, "iso password cleared"
);
StatusCode::NO_CONTENT.into_response()
}
Err(e) => (StatusCode::NOT_FOUND, format!("{e}")).into_response(),
}
}
async fn api_upload_iso(State(state): State<AppState>, mut multipart: Multipart) -> Response {
while let Ok(Some(mut field)) = multipart.next_field().await { while let Ok(Some(mut field)) = multipart.next_field().await {
if field.name() != Some("file") { continue; } if field.name() != Some("file") {
continue;
}
let filename = field.file_name().unwrap_or("uploaded.iso").to_string(); let filename = field.file_name().unwrap_or("uploaded.iso").to_string();
if !filename.to_ascii_lowercase().ends_with(".iso") { if !filename.to_ascii_lowercase().ends_with(".iso") {
return (StatusCode::BAD_REQUEST, "only .iso uploads accepted").into_response(); return (StatusCode::BAD_REQUEST, "only .iso uploads accepted").into_response();
@@ -383,7 +578,11 @@ async fn api_upload_iso(
async fn healthz() -> Response { async fn healthz() -> Response {
// Simple liveness — HTTP task is responsive. Does not touch storage or // Simple liveness — HTTP task is responsive. Does not touch storage or
// other subsystems so we never fail for downstream reasons. // other subsystems so we never fail for downstream reasons.
([(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))], "ok\n").into_response() (
[(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))],
"ok\n",
)
.into_response()
} }
async fn readyz(State(state): State<AppState>) -> Response { async fn readyz(State(state): State<AppState>) -> Response {
@@ -401,7 +600,11 @@ async fn readyz(State(state): State<AppState>) -> Response {
problems.push("iso directory not readable"); problems.push("iso directory not readable");
} }
if problems.is_empty() { if problems.is_empty() {
([(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))], "ready\n").into_response() (
[(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))],
"ready\n",
)
.into_response()
} else { } else {
let body = format!("not ready:\n- {}\n", problems.join("\n- ")); let body = format!("not ready:\n- {}\n", problems.join("\n- "));
(StatusCode::SERVICE_UNAVAILABLE, body).into_response() (StatusCode::SERVICE_UNAVAILABLE, body).into_response()
@@ -420,17 +623,22 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
let nfs_active = nfs.iter().filter(|m| m.mounted).count(); let nfs_active = nfs.iter().filter(|m| m.mounted).count();
let isos = state.iso_store.list(); let isos = state.iso_store.list();
let clients = state.clients.list(); let clients = state.clients.list();
let gates = state.queue.list(); let queue_entries = state.queue.list();
// Phase 4: dashboard tracks "imaging" as gates with an assignment // Phase 4: dashboard tracks "imaging" as queue entries with an assignment
// already issued — they're the ones actively chaining a boot script. // already issued — they're the ones actively chaining a boot script.
let imaging = gates.iter().filter(|g| g.assigned_target.is_some()).count(); let imaging = queue_entries
let waiting = gates.len() - imaging; .iter()
.filter(|entry| entry.assigned_target.is_some())
.count();
let waiting = queue_entries.len() - imaging;
// Side-effect: push gauge values out to the Prometheus surface. // Side-effect: push gauge values out to the Prometheus surface.
// Doing it here (in the most-frequently-polled endpoint) keeps the // Doing it here (in the most-frequently-polled endpoint) keeps the
// gauges fresh without a dedicated scrape-time hook. // gauges fresh without a dedicated scrape-time hook.
state.metrics.set_iso_count(isos.len() as u64); state.metrics.set_iso_count(isos.len() as u64);
state.metrics.set_client_count(clients.len() as u64); state.metrics.set_client_count(clients.len() as u64);
state.metrics.set_queue_counts(gates.len() as u64, imaging as u64); state
.metrics
.set_queue_counts(queue_entries.len() as u64, imaging as u64);
state.metrics.set_nfs_active(nfs_active as u64); state.metrics.set_nfs_active(nfs_active as u64);
state.metrics.record_http(openpxe_core::HttpRoute::Api); state.metrics.record_http(openpxe_core::HttpRoute::Api);
let now = time::OffsetDateTime::now_utc(); let now = time::OffsetDateTime::now_utc();
@@ -440,7 +648,7 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
"public_base_url": state.public_base_url, "public_base_url": state.public_base_url,
"iso_count": isos.len(), "iso_count": isos.len(),
"client_count": clients.len(), "client_count": clients.len(),
"queue_count": gates.len(), "queue_count": queue_entries.len(),
"imaging_count": imaging, "imaging_count": imaging,
"waiting_count": waiting, "waiting_count": waiting,
"ipxe_assets": openpxe_ipxe_assets::list_assets(), "ipxe_assets": openpxe_ipxe_assets::list_assets(),
@@ -475,7 +683,8 @@ async fn api_put_settings(
"cannot enable Windows: 'wimboot' binary is not bundled. \ "cannot enable Windows: 'wimboot' binary is not bundled. \
Place a signed wimboot build at assets/ipxe/wimboot and rebuild \ Place a signed wimboot build at assets/ipxe/wimboot and rebuild \
the container. See docs/architecture.md for details.", the container. See docs/architecture.md for details.",
).into_response(); )
.into_response();
} }
} }
new.smb_host_override = new.smb_host_override.trim().to_string(); new.smb_host_override = new.smb_host_override.trim().to_string();
@@ -488,9 +697,15 @@ async fn api_put_settings(
if let Some(smb) = &state.smb { if let Some(smb) = &state.smb {
match (was_enabled, want_enabled) { match (was_enabled, want_enabled) {
(false, true) => { let _ = smb.start(); } (false, true) => {
(true, false) => { smb.stop(); } let _ = smb.start();
(true, true) => { let _ = smb.reconcile(); } }
(true, false) => {
smb.stop();
}
(true, true) => {
let _ = smb.reconcile();
}
(false, false) => {} (false, false) => {}
} }
} }
@@ -507,7 +722,7 @@ async fn api_list_queue(State(state): State<AppState>) -> Json<serde_json::Value
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct GateJoinParams { struct QueueJoinParams {
/// Client MAC from iPXE's `${mac}` variable. iPXE substitutes before /// Client MAC from iPXE's `${mac}` variable. iPXE substitutes before
/// the HTTP request so we receive a plain colon-separated MAC. /// the HTTP request so we receive a plain colon-separated MAC.
mac: Option<String>, mac: Option<String>,
@@ -518,7 +733,7 @@ struct GateJoinParams {
/// until poll returns an actual boot script. /// until poll returns an actual boot script.
async fn api_queue_join( async fn api_queue_join(
State(state): State<AppState>, State(state): State<AppState>,
Query(p): Query<GateJoinParams>, Query(p): Query<QueueJoinParams>,
headers: HeaderMap, headers: HeaderMap,
) -> Response { ) -> Response {
let mac = p.mac.unwrap_or_else(|| "unknown".to_string()); let mac = p.mac.unwrap_or_else(|| "unknown".to_string());
@@ -528,10 +743,14 @@ async fn api_queue_join(
.and_then(|s| s.split(',').next()) .and_then(|s| s.split(',').next())
.and_then(|s| s.trim().parse().ok()); .and_then(|s| s.trim().parse().ok());
let gate = state.queue.join(&mac, ip, None); let queue_entry = state.queue.join(&mac, ip, None);
state.clients.record( state.clients.record(
&mac, ip, None, &mac,
ClientEvent::HttpScriptFetch { target: "queue-join".into() }, ip,
None,
ClientEvent::HttpScriptFetch {
target: "queue-join".into(),
},
); );
let base = &state.public_base_url; let base = &state.public_base_url;
@@ -540,12 +759,12 @@ async fn api_queue_join(
"#!ipxe\n\ "#!ipxe\n\
echo\n\ echo\n\
echo ==========================================\n\ echo ==========================================\n\
echo Queued Deployment - Gate Position {}\n\ echo Queued Deployment - Queue Position {}\n\
echo Waiting for operator to assign an image\n\ echo Waiting for operator to assign an image\n\
echo (Ctrl-B returns to the iPXE shell)\n\ echo (Ctrl-B returns to the iPXE shell)\n\
echo ==========================================\n\ echo ==========================================\n\
chain {base}/api/queue/poll/{}\n", chain {base}/api/queue/poll/{}\n",
gate.position, gate.id queue_entry.position, queue_entry.id
); );
text_plain(script) text_plain(script)
} }
@@ -558,7 +777,7 @@ async fn api_queue_poll(
AxumPath(entry_id): AxumPath<String>, AxumPath(entry_id): AxumPath<String>,
) -> Response { ) -> Response {
let Some(notify) = state.queue.notifier(&entry_id) else { let Some(notify) = state.queue.notifier(&entry_id) else {
// Gate was released; send client back to the main menu. // Queue entry was released; send client back to the main menu.
let base = &state.public_base_url; let base = &state.public_base_url;
return text_plain(format!("#!ipxe\nchain {base}/boot.ipxe\n")); return text_plain(format!("#!ipxe\nchain {base}/boot.ipxe\n"));
}; };
@@ -571,7 +790,7 @@ async fn api_queue_poll(
match snap { match snap {
// Bind `target` directly so we can't observe an Option::None between // 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 // 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 // `release`). We also do NOT release the queue entry here — the web UI
// operator releases it explicitly, which keeps a record of "this // operator releases it explicitly, which keeps a record of "this
// machine was assigned image X" visible until the client is known // machine was assigned image X" visible until the client is known
// to have started. Clients that retry on transient network errors // to have started. Clients that retry on transient network errors
@@ -582,20 +801,20 @@ async fn api_queue_poll(
tracing::info!( tracing::info!(
target: "openpxe::queue", target: "openpxe::queue",
entry_id=%entry_id, mac=%g.mac, target=%target, entry_id=%entry_id, mac=%g.mac, target=%target,
"gate assignment delivered" "queue assignment delivered"
); );
text_plain(format!( text_plain(format!(
"#!ipxe\n\ "#!ipxe\n\
echo Gate assignment received: {target}\n\ echo Queue assignment received: {target}\n\
chain {base}/boot/{target}.ipxe || chain {base}/api/queue/poll/{entry_id}\n" chain {base}/boot/{target}.ipxe || chain {base}/api/queue/poll/{entry_id}\n"
)) ))
} }
Some(g) => { Some(g) => {
// No assignment yet - loop and re-poll. Repaint position so the // No assignment yet - loop and re-poll. Repaint position so the
// UI count stays accurate if other gates were released meanwhile. // UI count stays accurate if other queue entries were released meanwhile.
text_plain(format!( text_plain(format!(
"#!ipxe\n\ "#!ipxe\n\
echo Gate Position {} - still waiting\n\ echo Queue Position {} - still waiting\n\
chain {base}/api/queue/poll/{entry_id}\n", chain {base}/api/queue/poll/{entry_id}\n",
g.position g.position
)) ))
@@ -605,27 +824,34 @@ async fn api_queue_poll(
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct GateAssignBody { struct QueueAssignBody {
/// Boot entry id (from `BootEntry::id`). Same one used in /// Boot entry id (from `BootEntry::id`). Same one used in
/// `/boot/<id>.ipxe`. /// `/boot/<id>.ipxe`.
target: String, target: String,
/// Gate ids to assign. Empty = assign to all currently queued gates. /// Queue entry ids to assign. Empty = assign to all currently queued clients.
entry_ids: Vec<String>, entry_ids: Vec<String>,
} }
async fn api_queue_assign( async fn api_queue_assign(
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<GateAssignBody>, Json(body): Json<QueueAssignBody>,
) -> Json<serde_json::Value> { ) -> Json<serde_json::Value> {
let ids = if body.entry_ids.is_empty() { let ids = if body.entry_ids.is_empty() {
state.queue.list().into_iter().map(|g| g.id).collect::<Vec<_>>() state
.queue
.list()
.into_iter()
.map(|g| g.id)
.collect::<Vec<_>>()
} else { } else {
body.entry_ids body.entry_ids
}; };
// Guard: target must exist as a BootEntry id. // Guard: target must exist as a BootEntry id.
let found = state.iso_store.list().into_iter().any(|i| { let found = state
i.boot_entries.iter().any(|e| e.id == body.target) .iso_store
}); .list()
.into_iter()
.any(|i| i.boot_entries.iter().any(|e| e.id == body.target));
if !found { if !found {
return Json(json!({ "ok": false, "error": "unknown target" })); return Json(json!({ "ok": false, "error": "unknown target" }));
} }
@@ -649,10 +875,7 @@ async fn api_nfs_list(State(state): State<AppState>) -> Json<serde_json::Value>
Json(json!({ "mounts": state.nfs.list() })) Json(json!({ "mounts": state.nfs.list() }))
} }
async fn api_nfs_add( async fn api_nfs_add(State(state): State<AppState>, Json(req): Json<NfsAddRequest>) -> Response {
State(state): State<AppState>,
Json(req): Json<NfsAddRequest>,
) -> Response {
match state.nfs.add(req).await { match state.nfs.add(req).await {
Ok(m) => (StatusCode::CREATED, Json(m)).into_response(), Ok(m) => (StatusCode::CREATED, Json(m)).into_response(),
// Anything from the manager surfaces as a user-fixable validation // Anything from the manager surfaces as a user-fixable validation
@@ -663,20 +886,14 @@ async fn api_nfs_add(
} }
} }
async fn api_nfs_remove( async fn api_nfs_remove(State(state): State<AppState>, AxumPath(id): AxumPath<String>) -> Response {
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Response {
match state.nfs.remove(&id).await { match state.nfs.remove(&id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(), Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
} }
} }
async fn api_nfs_scan( async fn api_nfs_scan(State(state): State<AppState>, AxumPath(id): AxumPath<String>) -> Response {
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Response {
match state.nfs.rescan(&id).await { match state.nfs.rescan(&id).await {
Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(), Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(),
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(), Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
@@ -783,11 +1000,14 @@ async fn api_metrics(State(state): State<AppState>) -> Response {
state state
.metrics .metrics
.set_client_count(state.clients.list().len() as u64); .set_client_count(state.clients.list().len() as u64);
let gates = state.queue.list(); let queue_entries = state.queue.list();
let imaging = gates.iter().filter(|g| g.assigned_target.is_some()).count(); let imaging = queue_entries
.iter()
.filter(|entry| entry.assigned_target.is_some())
.count();
state state
.metrics .metrics
.set_queue_counts(gates.len() as u64, imaging as u64); .set_queue_counts(queue_entries.len() as u64, imaging as u64);
state state
.metrics .metrics
.set_nfs_active(state.nfs.list().iter().filter(|m| m.mounted).count() as u64); .set_nfs_active(state.nfs.list().iter().filter(|m| m.mounted).count() as u64);
@@ -811,25 +1031,39 @@ mod tests {
#[test] #[test]
fn range_full() { fn range_full() {
let (s, e, p) = parse_range(None, 1000); let (s, e, p) = parse_range(None, 1000).unwrap();
assert_eq!((s, e, p), (0, 999, false)); assert_eq!((s, e, p), (0, 999, false));
} }
#[test] #[test]
fn range_open_ended() { fn range_open_ended() {
let h = HeaderValue::from_static("bytes=500-"); let h = HeaderValue::from_static("bytes=500-");
let (s, e, p) = parse_range(Some(&h), 1000); let (s, e, p) = parse_range(Some(&h), 1000).unwrap();
assert_eq!((s, e, p), (500, 999, true)); assert_eq!((s, e, p), (500, 999, true));
} }
#[test] #[test]
fn range_suffix() { fn range_suffix() {
let h = HeaderValue::from_static("bytes=-100"); let h = HeaderValue::from_static("bytes=-100");
let (s, e, p) = parse_range(Some(&h), 1000); let (s, e, p) = parse_range(Some(&h), 1000).unwrap();
assert_eq!((s, e, p), (900, 999, true)); assert_eq!((s, e, p), (900, 999, true));
} }
#[test] #[test]
fn range_explicit() { fn range_explicit() {
let h = HeaderValue::from_static("bytes=10-99"); let h = HeaderValue::from_static("bytes=10-99");
let (s, e, p) = parse_range(Some(&h), 1000); let (s, e, p) = parse_range(Some(&h), 1000).unwrap();
assert_eq!((s, e, p), (10, 99, true)); assert_eq!((s, e, p), (10, 99, true));
} }
#[test]
fn range_rejects_out_of_bounds_start() {
let h = HeaderValue::from_static("bytes=1000-");
let got = parse_range(Some(&h), 1000);
assert_eq!(got, None);
}
#[test]
fn range_rejects_start_after_end() {
let h = HeaderValue::from_static("bytes=99-10");
let got = parse_range(Some(&h), 1000);
assert_eq!(got, None);
}
} }
+243 -31
View File
@@ -23,8 +23,8 @@
//! There is intentionally no UI path to upload a custom `.ipxe` script. //! There is intentionally no UI path to upload a custom `.ipxe` script.
use openpxe_core::{Settings, TimeoutAction}; use openpxe_core::{Settings, TimeoutAction};
use openpxe_iso_store::{BootEntry, BootKind, IsoMeta};
use openpxe_iso_store::introspect::DistroFamily; use openpxe_iso_store::introspect::DistroFamily;
use openpxe_iso_store::{BootEntry, BootKind, IsoMeta};
use std::fmt::Write as _; use std::fmt::Write as _;
/// Top-level OpenPXE boot menu. Serialized identically for BIOS and UEFI /// Top-level OpenPXE boot menu. Serialized identically for BIOS and UEFI
@@ -49,9 +49,15 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
let _ = writeln!(s, "set cls ${{esc:string}}[2J"); let _ = writeln!(s, "set cls ${{esc:string}}[2J");
let _ = writeln!(s, ":menu"); let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu OpenPXE - network boot menu"); let _ = writeln!(s, "menu OpenPXE - network boot menu");
let _ = writeln!(s, "item --gap -- ------------------------- Default -------------------------"); let _ = writeln!(
s,
"item --gap -- ------------------------- Default -------------------------"
);
let _ = writeln!(s, "item local Boot from Local HDD"); let _ = writeln!(s, "item local Boot from Local HDD");
let _ = writeln!(s, "item --gap -- ----------------------- Installers -----------------------"); let _ = writeln!(
s,
"item --gap -- ----------------------- Installers -----------------------"
);
if has_family(isos, is_linux_family) { if has_family(isos, is_linux_family) {
let _ = writeln!(s, "item linux Linux Installers >"); let _ = writeln!(s, "item linux Linux Installers >");
} else { } else {
@@ -64,9 +70,15 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
} else { } else {
let _ = writeln!(s, "item --gap -- (Windows support disabled in Settings)"); let _ = writeln!(s, "item --gap -- (Windows support disabled in Settings)");
} }
let _ = writeln!(s, "item --gap -- -------------------------- Tools --------------------------"); let _ = writeln!(
s,
"item --gap -- -------------------------- Tools --------------------------"
);
let _ = writeln!(s, "item tools Tools >"); let _ = writeln!(s, "item tools Tools >");
let _ = writeln!(s, "item --gap -- ---------------------- Queued Deployment ---------------------"); let _ = writeln!(
s,
"item --gap -- ---------------------- Queued Deployment ---------------------"
);
let _ = writeln!(s, "item queue Queued Deployment (join queue)"); let _ = writeln!(s, "item queue Queued Deployment (join queue)");
let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key x exit Exit iPXE"); let _ = writeln!(s, "item --key x exit Exit iPXE");
@@ -74,18 +86,39 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
if matches!(settings.timeout_action, TimeoutAction::Stay) { if matches!(settings.timeout_action, TimeoutAction::Stay) {
let _ = writeln!(s, "choose --default {default_item} target || goto menu"); let _ = writeln!(s, "choose --default {default_item} target || goto menu");
} else { } else {
let _ = writeln!(s, "choose --default {default_item} --timeout {timeout_ms} target || goto menu"); 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` // iPXE's `||` is strict about what follows. Each test uses `goto menu`
// as the fallthrough target so the parser never sees a bare `||` with // as the fallthrough target so the parser never sees a bare `||` with
// trailing whitespace — some iPXE builds reject that. // trailing whitespace — some iPXE builds reject that.
let _ = writeln!(s, "iseq ${{target}} local && chain {base}/boot/_local.ipxe || goto menu"); let _ = writeln!(
let _ = writeln!(s, "iseq ${{target}} linux && chain {base}/boot/_linux_menu.ipxe || goto menu"); s,
let _ = writeln!(s, "iseq ${{target}} windows && chain {base}/boot/_windows_menu.ipxe || goto menu"); "iseq ${{target}} local && chain {base}/boot/_local.ipxe || goto menu"
let _ = writeln!(s, "iseq ${{target}} tools && chain {base}/boot/_tools_menu.ipxe || goto menu"); );
let _ = writeln!(s, "iseq ${{target}} queue && chain {base}/boot/_queue.ipxe || goto menu"); let _ = writeln!(
let _ = writeln!(s, "iseq ${{target}} exit && exit || goto menu"); 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}} queue && chain {base}/boot/_queue.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} exit && exit || goto menu"
);
let _ = writeln!(s, "goto menu"); let _ = writeln!(s, "goto menu");
s s
} }
@@ -95,25 +128,44 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
#[must_use] #[must_use]
pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) -> String { pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) -> String {
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
let title = if is_windows { "Windows Installers" } else { "Linux Installers" }; let title = if is_windows {
"Windows Installers"
} else {
"Linux Installers"
};
let label = if is_windows { "windows" } else { "linux" }; let label = if is_windows { "windows" } else { "linux" };
let mut s = String::new(); let mut s = String::new();
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "set base-url {base}"); let _ = writeln!(s, "set base-url {base}");
let _ = writeln!(s, ":menu"); let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu OpenPXE - {title}"); let _ = writeln!(s, "menu OpenPXE - {title}");
let filter: fn(DistroFamily) -> bool = let filter: fn(DistroFamily) -> bool = if is_windows {
if is_windows { is_windows_family } else { is_linux_family }; is_windows_family
} else {
is_linux_family
};
let mut count = 0; let mut count = 0;
for iso in isos { for iso in isos {
if !filter(iso.introspection.family) { continue; } if !filter(iso.introspection.family) {
continue;
}
for entry in &iso.boot_entries { for entry in &iso.boot_entries {
let size_label = fmt_size_mib(iso.size_bytes); let size_label = fmt_size_mib(iso.size_bytes);
let key = hotkey_for_index(count); let key = hotkey_for_index(count);
// Visual hint: a leading `*` marks password-protected entries.
// ASCII only — iPXE's menu console mangles non-ASCII on some
// firmwares.
let lock = if iso.is_password_protected() {
"*"
} else {
" "
};
let _ = writeln!( let _ = writeln!(
s, "item {}{} [{:>6}] {}", s,
"item {}{} {}[{:>6}] {}",
key, key,
entry.id, entry.id,
lock,
size_label, size_label,
escape_label(&entry.title), escape_label(&entry.title),
); );
@@ -126,7 +178,10 @@ pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) ->
let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key b back < Back to main menu"); let _ = writeln!(s, "item --key b back < Back to main menu");
let _ = writeln!(s, "choose target || goto menu"); let _ = writeln!(s, "choose target || goto menu");
let _ = writeln!(s, "iseq ${{target}} back && chain {base}/boot.ipxe || goto menu"); let _ = writeln!(
s,
"iseq ${{target}} back && chain {base}/boot.ipxe || goto menu"
);
let _ = writeln!(s, "chain {base}/boot/${{target}}.ipxe || goto menu"); let _ = writeln!(s, "chain {base}/boot/${{target}}.ipxe || goto menu");
s s
} }
@@ -166,12 +221,30 @@ pub fn render_tools_menu(base_url: &str) -> String {
let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key b back < Back to main menu"); let _ = writeln!(s, "item --key b back < Back to main menu");
let _ = writeln!(s, "choose target || goto menu"); let _ = writeln!(s, "choose target || goto menu");
let _ = writeln!(s, "iseq ${{target}} util && chain {base}/boot/_util.ipxe || goto menu"); let _ = writeln!(
let _ = writeln!(s, "iseq ${{target}} shell && chain {base}/boot/_shell.ipxe || goto menu"); s,
let _ = writeln!(s, "iseq ${{target}} nic && chain {base}/boot/_nic.ipxe || goto menu"); "iseq ${{target}} util && chain {base}/boot/_util.ipxe || goto menu"
let _ = writeln!(s, "iseq ${{target}} reboot && reboot || goto menu"); );
let _ = writeln!(s, "iseq ${{target}} firmware && exit 0 || goto menu"); let _ = writeln!(
let _ = writeln!(s, "iseq ${{target}} back && chain {base}/boot.ipxe || goto menu"); 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"); let _ = writeln!(s, "goto menu");
s s
} }
@@ -185,8 +258,15 @@ pub fn render_local_hdd(base_url: &str) -> String {
let mut s = String::new(); let mut s = String::new();
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "# Boot from Local HDD - platform-sensitive"); let _ = writeln!(s, "# Boot from Local HDD - platform-sensitive");
let _ = writeln!(s, "iseq ${{platform}} pcbios && sanboot --no-describe --drive 0x80 || "); let _ = writeln!(
let _ = writeln!(s, "# UEFI path: fall through to the firmware's next boot entry"); s,
"iseq ${{platform}} pcbios && sanboot --no-describe --drive 0x80 || goto uefi"
);
let _ = writeln!(s, ":uefi");
let _ = writeln!(
s,
"# UEFI path: fall through to the firmware's next boot entry"
);
let _ = writeln!(s, "exit 0"); let _ = writeln!(s, "exit 0");
let _ = writeln!(s, "# If the above exit returns, loop back to the main menu"); let _ = writeln!(s, "# If the above exit returns, loop back to the main menu");
let _ = writeln!(s, "chain {base}/boot.ipxe"); let _ = writeln!(s, "chain {base}/boot.ipxe");
@@ -207,8 +287,14 @@ pub fn render_util(base_url: &str) -> String {
let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item back < Back"); let _ = writeln!(s, "item back < Back");
let _ = writeln!(s, "choose target || goto menu"); let _ = writeln!(s, "choose target || goto menu");
let _ = writeln!(s, "iseq ${{target}} memtest && chain {base}/ipxe/memtest.bin || "); let _ = writeln!(
let _ = writeln!(s, "iseq ${{target}} back && chain {base}/boot/_tools_menu.ipxe || "); s,
"iseq ${{target}} memtest && chain {base}/ipxe/memtest.bin || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} back && chain {base}/boot/_tools_menu.ipxe || goto menu"
);
let _ = writeln!(s, "goto menu"); let _ = writeln!(s, "goto menu");
s s
} }
@@ -255,7 +341,10 @@ pub fn render_queue_entry(base_url: &str) -> String {
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
let mut s = String::new(); let mut s = String::new();
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "# Queued Deployment - join the queue and wait for operator"); let _ = writeln!(
s,
"# Queued Deployment - join the queue and wait for operator"
);
let _ = writeln!(s, "echo Joining deployment queue..."); let _ = writeln!(s, "echo Joining deployment queue...");
// imgfetch writes the body to a file in iPXE's transient FS; we read // imgfetch writes the body to a file in iPXE's transient FS; we read
// the queue entry id out of the Location-style header by asking the server // the queue entry id out of the Location-style header by asking the server
@@ -272,7 +361,11 @@ pub fn render_entry(entry: &BootEntry, settings: &Settings, base_url: &str) -> S
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "set base-url {base}"); let _ = writeln!(s, "set base-url {base}");
match &entry.kind { match &entry.kind {
BootKind::LinuxKernel { kernel_url, initrd_urls, args } => { BootKind::LinuxKernel {
kernel_url,
initrd_urls,
args,
} => {
let mut cmdline = args.cmdline.replace("${base-url}", base); let mut cmdline = args.cmdline.replace("${base-url}", base);
if !settings.extra_kernel_args.trim().is_empty() { if !settings.extra_kernel_args.trim().is_empty() {
cmdline.push(' '); cmdline.push(' ');
@@ -323,5 +416,124 @@ fn has_family(isos: &[IsoMeta], pred: fn(DistroFamily) -> bool) -> bool {
} }
fn escape_label(s: &str) -> String { fn escape_label(s: &str) -> String {
s.chars().map(|c| match c { '\n' | '\r' => ' ', c => c }).collect() s.chars()
.map(|c| match c {
'\n' | '\r' => ' ',
c => c,
})
.collect()
}
/// Render the password-prompt script for a protected boot entry.
///
/// Flow on the client:
/// 1. iPXE clears any leftover ${password}, prints a banner naming the
/// ISO so the operator knows what they're being asked for.
/// 2. `read --secret password` accepts input without echoing it to
/// the screen.
/// 3. An empty input bails back to the main menu (lets the operator
/// back out of a misclick).
/// 4. Otherwise the script chains the same /boot/<id>.ipxe URL but
/// with `?token=${password:uristring}`. iPXE's `:uristring`
/// modifier URL-encodes the value so `&`, `?`, `=`, spaces, etc.
/// survive transport.
/// 5. The server replies with either the boot script (correct
/// password) or [`render_password_failed`] (wrong password). On
/// transport failure we fall back to the main menu.
#[must_use]
pub fn render_password_prompt(entry_id: &str, iso_filename: &str, base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let label = escape_label(iso_filename);
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "# OpenPXE password prompt for {label}");
let _ = writeln!(s, "echo");
let _ = writeln!(s, "echo ==========================================");
let _ = writeln!(s, "echo This image requires a password");
let _ = writeln!(s, "echo {label}");
let _ = writeln!(s, "echo (enter alone returns to main menu)");
let _ = writeln!(s, "echo ==========================================");
let _ = writeln!(s, "set password ");
let _ = writeln!(s, "read --secret password");
let _ = writeln!(
s,
"iseq ${{password}} \"\" && chain {base}/boot.ipxe || goto submit"
);
let _ = writeln!(s, ":submit");
let _ = writeln!(s, "echo Verifying...");
let _ = writeln!(
s,
"chain {base}/boot/{entry_id}.ipxe?token=${{password:uristring}} || chain {base}/boot.ipxe"
);
s
}
/// Render the "wrong password" script. Tells the operator, sleeps for
/// two seconds (gives the eye time to register the message and dampens
/// brute-force rate without help from the server), and chains back to
/// the same entry — which sends them through the prompt flow again.
#[must_use]
pub fn render_password_failed(entry_id: &str, 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 Wrong password.");
let _ = writeln!(s, "sleep 2");
let _ = writeln!(
s,
"chain {base}/boot/{entry_id}.ipxe || chain {base}/boot.ipxe"
);
s
}
#[cfg(test)]
mod password_tests {
use super::*;
#[test]
fn prompt_uses_secret_read_and_uri_escape() {
let s = render_password_prompt("alpha-linux", "Alpha Test.iso", "http://10.0.0.5");
assert!(s.starts_with("#!ipxe\n"));
assert!(s.contains("read --secret password"));
assert!(s.contains("Alpha Test.iso"));
// URI-string modifier on the var so passwords with `&`/spaces survive.
assert!(s.contains("token=${password:uristring}"));
// Empty enter sends back to the main menu, not back into the prompt
// (avoids a wedged client if the operator chose by mistake).
assert!(s.contains("&& chain http://10.0.0.5/boot.ipxe || goto submit"));
// Never log/echo the value.
assert!(!s.contains("echo ${password"));
}
#[test]
fn failed_chains_back_to_entry() {
let s = render_password_failed("alpha-linux", "http://10.0.0.5");
assert!(s.contains("Wrong password."));
// Re-target the entry so the prompt flow runs again.
assert!(s.contains("chain http://10.0.0.5/boot/alpha-linux.ipxe"));
}
#[test]
fn generated_scripts_do_not_emit_bare_or_trailing_fallbacks() {
let settings = Settings::default();
let scripts = [
render_menu(&[], &settings, "http://10.0.0.5"),
render_tools_menu("http://10.0.0.5"),
render_local_hdd("http://10.0.0.5"),
render_util("http://10.0.0.5"),
render_shell("http://10.0.0.5"),
render_nic_info("http://10.0.0.5"),
render_queue_entry("http://10.0.0.5"),
render_password_failed("alpha-linux", "http://10.0.0.5"),
];
for script in scripts {
for line in script.lines() {
assert!(
!line.trim_end().ends_with("||"),
"bare iPXE fallback operator in line: {line}\nscript:\n{script}"
);
}
}
}
} }
+33 -10
View File
@@ -31,7 +31,9 @@ pub fn lookup(iso_path: &Path, in_iso_path: &str) -> Option<FileLocation> {
.split('/') .split('/')
.filter(|c| !c.is_empty()) .filter(|c| !c.is_empty())
.collect(); .collect();
if components.is_empty() { return None; } if components.is_empty() {
return None;
}
walk(&mut f, root.offset, root.length, &components) walk(&mut f, root.offset, root.length, &components)
} }
@@ -40,11 +42,16 @@ fn read_root_directory(f: &mut std::fs::File) -> Option<FileLocation> {
let mut pvd = [0u8; 2048]; let mut pvd = [0u8; 2048];
f.seek(SeekFrom::Start(16 * SECTOR)).ok()?; f.seek(SeekFrom::Start(16 * SECTOR)).ok()?;
f.read_exact(&mut pvd).ok()?; f.read_exact(&mut pvd).ok()?;
if pvd[0] != 0x01 || &pvd[1..6] != b"CD001" { return None; } if pvd[0] != 0x01 || &pvd[1..6] != b"CD001" {
return None;
}
// Root directory record is at offset 156, length 34. // Root directory record is at offset 156, length 34.
let rec = &pvd[156..156 + 34]; let rec = &pvd[156..156 + 34];
let (offset, length) = parse_dir_record_ext(rec)?; let (offset, length) = parse_dir_record_ext(rec)?;
Some(FileLocation { offset: offset * SECTOR, length }) Some(FileLocation {
offset: offset * SECTOR,
length,
})
} }
/// Walk components down the directory tree starting at `dir_offset`. /// Walk components down the directory tree starting at `dir_offset`.
@@ -66,21 +73,29 @@ fn walk(
if len == 0 { if len == 0 {
// Padding to sector boundary. // Padding to sector boundary.
let next = (i / SECTOR as usize + 1) * SECTOR as usize; let next = (i / SECTOR as usize + 1) * SECTOR as usize;
if next <= i { break; } if next <= i {
break;
}
i = next; i = next;
continue; continue;
} }
if i + len > dir.len() { break; } if i + len > dir.len() {
break;
}
let rec = &dir[i..i + len]; let rec = &dir[i..i + len];
let name = dir_record_name(rec); let name = dir_record_name(rec);
let is_dir = (rec.get(25).copied().unwrap_or(0) & 0x02) != 0; let is_dir = (rec.get(25).copied().unwrap_or(0) & 0x02) != 0;
// Skip "." (0x00) and ".." (0x01) pseudo-entries. // Skip "." (0x00) and ".." (0x01) pseudo-entries.
let is_pseudo = matches!(rec.get(32).copied(), Some(1)) && rec.get(33).copied() == Some(0x00) 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); || matches!(rec.get(32).copied(), Some(1)) && rec.get(33).copied() == Some(0x01);
if !is_pseudo && name.eq_ignore_ascii_case(target) { if !is_pseudo && name.eq_ignore_ascii_case(target) {
let (child_off, child_len) = parse_dir_record_ext(rec)?; let (child_off, child_len) = parse_dir_record_ext(rec)?;
if rest.is_empty() && !is_dir { if rest.is_empty() && !is_dir {
return Some(FileLocation { offset: child_off * SECTOR, length: child_len }); return Some(FileLocation {
offset: child_off * SECTOR,
length: child_len,
});
} else if !rest.is_empty() && is_dir { } else if !rest.is_empty() && is_dir {
return walk(f, child_off * SECTOR, child_len, rest); return walk(f, child_off * SECTOR, child_len, rest);
} }
@@ -94,7 +109,9 @@ fn walk(
/// Layout per ISO9660: bytes 2..10 extent LBA (LE+BE duplicate), 10..18 /// Layout per ISO9660: bytes 2..10 extent LBA (LE+BE duplicate), 10..18
/// data length (LE+BE duplicate). We trust the little-endian copy. /// data length (LE+BE duplicate). We trust the little-endian copy.
fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> { fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> {
if rec.len() < 34 { return None; } if rec.len() < 34 {
return None;
}
let lba = u32::from_le_bytes(rec[2..6].try_into().ok()?) as u64; 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; let len = u32::from_le_bytes(rec[10..14].try_into().ok()?) as u64;
Some((lba, len)) Some((lba, len))
@@ -104,9 +121,15 @@ fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> {
/// `;1` version suffix. /// `;1` version suffix.
fn dir_record_name(rec: &[u8]) -> String { fn dir_record_name(rec: &[u8]) -> String {
let name_len = *rec.get(32).unwrap_or(&0) as usize; let name_len = *rec.get(32).unwrap_or(&0) as usize;
if name_len == 0 || rec.len() < 33 + name_len { return String::new(); } if name_len == 0 || rec.len() < 33 + name_len {
return String::new();
}
let raw = &rec[33..33 + name_len]; let raw = &rec[33..33 + name_len];
let s = String::from_utf8_lossy(raw).to_string(); let s = String::from_utf8_lossy(raw).to_string();
// Strip `;N` version suffix. // Strip `;N` version suffix.
if let Some(i) = s.rfind(';') { s[..i].to_string() } else { s } if let Some(i) = s.rfind(';') {
s[..i].to_string()
} else {
s
}
} }
+10 -6
View File
@@ -36,9 +36,11 @@ pub async fn stream(
let rx = state.log_bus.subscribe(); let rx = state.log_bus.subscribe();
let live = BroadcastStream::new(rx).map(|res| match res { let live = BroadcastStream::new(rx).map(|res| match res {
Ok(line) => Ok(Event::default().data(line_json(&line))), Ok(line) => Ok(Event::default().data(line_json(&line))),
Err(tokio_stream::wrappers::errors::BroadcastStreamRecvError::Lagged(n)) => Ok(Event::default() Err(tokio_stream::wrappers::errors::BroadcastStreamRecvError::Lagged(n)) => {
.event("lagged") Ok(Event::default()
.data(json!({ "skipped": n }).to_string())), .event("lagged")
.data(json!({ "skipped": n }).to_string()))
}
}); });
Sse::new(recent_stream.chain(live)) Sse::new(recent_stream.chain(live))
@@ -55,9 +57,11 @@ pub async fn recent(State(state): State<AppState>) -> Json<serde_json::Value> {
/// keep streaming new lines as they arrive). /// keep streaming new lines as they arrive).
pub async fn clear(State(state): State<AppState>) -> Json<serde_json::Value> { pub async fn clear(State(state): State<AppState>) -> Json<serde_json::Value> {
state.log_bus.clear(); state.log_bus.clear();
state state.log_bus.push(
.log_bus "info",
.push("info", "openpxe::terminal", "log buffer cleared by operator"); "openpxe::terminal",
"log buffer cleared by operator",
);
Json(json!({ "ok": true })) Json(json!({ "ok": true }))
} }
+73 -47
View File
@@ -31,12 +31,17 @@ pub async fn run_command(
) -> impl IntoResponse { ) -> impl IntoResponse {
let line = req.command.trim(); let line = req.command.trim();
if line.is_empty() { if line.is_empty() {
return (StatusCode::OK, Json(json!({ "output": HELP_TEXT, "ok": true }))); return (
StatusCode::OK,
Json(json!({ "output": HELP_TEXT, "ok": true })),
);
} }
// Echo the typed command into the live log so the Terminal tab shows // Echo the typed command into the live log so the Terminal tab shows
// operator activity in-band with server-emitted log lines. // operator activity in-band with server-emitted log lines.
state.log_bus.push("info", "openpxe::terminal", format!("> {line}")); state
.log_bus
.push("info", "openpxe::terminal", format!("> {line}"));
let argv = shell_split(line); let argv = shell_split(line);
if argv.is_empty() { if argv.is_empty() {
@@ -55,7 +60,11 @@ pub async fn run_command(
// so reading the live tail tells the same story as scrolling the // so reading the live tail tells the same story as scrolling the
// terminal pane. // terminal pane.
let mirror = if output.len() > 1024 { let mirror = if output.len() > 1024 {
format!("{}\n... ({} bytes truncated)", &output[..1024], output.len() - 1024) format!(
"{}\n... ({} bytes truncated)",
&output[..1024],
output.len() - 1024
)
} else { } else {
output.clone() output.clone()
}; };
@@ -78,7 +87,7 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
"status" => Ok(status_text(state)), "status" => Ok(status_text(state)),
"isos" | "images" => Ok(isos_text(state)), "isos" | "images" => Ok(isos_text(state)),
"clients" => Ok(clients_text(state)), "clients" => Ok(clients_text(state)),
"queue" => gate_command(state, tail).await, "queue" => queue_command(state, tail).await,
"nfs" => nfs_command(state, tail).await, "nfs" => nfs_command(state, tail).await,
"smb" => smb_command(state, tail).await, "smb" => smb_command(state, tail).await,
"log" => log_command(state, tail), "log" => log_command(state, tail),
@@ -96,7 +105,7 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
fn status_text(s: &AppState) -> String { fn status_text(s: &AppState) -> String {
let isos = s.iso_store.list(); let isos = s.iso_store.list();
let clients = s.clients.list(); let clients = s.clients.list();
let gates = s.queue.list(); let queue_entries = s.queue.list();
let smb = s.smb.as_ref().map(|m| m.snapshot()); let smb = s.smb.as_ref().map(|m| m.snapshot());
let nfs = s.nfs.list(); let nfs = s.nfs.list();
let nfs_active = nfs.iter().filter(|m| m.mounted).count(); let nfs_active = nfs.iter().filter(|m| m.mounted).count();
@@ -112,13 +121,23 @@ fn status_text(s: &AppState) -> String {
nfs mounts: {n_total} configured ({n_active} active)\n", nfs mounts: {n_total} configured ({n_active} active)\n",
ver = env!("CARGO_PKG_VERSION"), ver = env!("CARGO_PKG_VERSION"),
base = s.public_base_url, base = s.public_base_url,
nic = if s.nic_name.is_empty() { "?" } else { s.nic_name.as_str() }, nic = if s.nic_name.is_empty() {
"?"
} else {
s.nic_name.as_str()
},
up = uptime_string(s), up = uptime_string(s),
n_isos = isos.len(), n_isos = isos.len(),
n_local = isos.iter().filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Local)).count(), n_local = isos
n_nfs = isos.iter().filter(|i| !matches!(i.source, openpxe_iso_store::IsoSource::Local)).count(), .iter()
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Local))
.count(),
n_nfs = isos
.iter()
.filter(|i| !matches!(i.source, openpxe_iso_store::IsoSource::Local))
.count(),
n_clients = clients.len(), n_clients = clients.len(),
n_entries = gates.len(), n_entries = queue_entries.len(),
smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")), smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")),
n_total = nfs.len(), n_total = nfs.len(),
n_active = nfs_active, n_active = nfs_active,
@@ -159,11 +178,7 @@ fn clients_text(s: &AppState) -> String {
return "(no clients yet)".into(); return "(no clients yet)".into();
} }
let mut out = String::new(); let mut out = String::new();
let _ = writeln!( let _ = writeln!(out, "{:<19} {:<16} {:<8} LAST SEEN", "MAC", "IP", "EVENTS");
out,
"{:<19} {:<16} {:<8} LAST SEEN",
"MAC", "IP", "EVENTS"
);
for c in clients { for c in clients {
let ip = c.last_ip.map_or_else(|| "-".into(), |i| i.to_string()); let ip = c.last_ip.map_or_else(|| "-".into(), |i| i.to_string());
let _ = writeln!( let _ = writeln!(
@@ -180,35 +195,35 @@ fn clients_text(s: &AppState) -> String {
out out
} }
// ── gate ────────────────────────────────────────────────────────────── // ── queue ──────────────────────────────────────────────────────────────
// `async` for symmetry with the other dispatch helpers — gate operations // `async` for symmetry with the other dispatch helpers — queue operations
// are sync today but might grow to await on a database in a future phase. // are sync today but might grow to await on a database in a future phase.
#[allow(clippy::unused_async)] #[allow(clippy::unused_async)]
async fn gate_command(s: &AppState, args: &[String]) -> Result<String, String> { async fn queue_command(s: &AppState, args: &[String]) -> Result<String, String> {
match args.first().map(String::as_str) { match args.first().map(String::as_str) {
None | Some("list") => { None | Some("list") => {
let gs = s.queue.list(); let entries = s.queue.list();
if gs.is_empty() { if entries.is_empty() {
return Ok("(no gates)".into()); return Ok("(queue empty)".into());
} }
let mut out = String::new(); let mut out = String::new();
for g in gs { for entry in entries {
let _ = writeln!( let _ = writeln!(
out, out,
"#{:<3} {:<19} {:<16} target={}", "#{:<3} {:<19} {:<16} target={}",
g.position, entry.position,
g.mac, entry.mac,
g.id, entry.id,
g.assigned_target.unwrap_or_else(|| "-".into()) entry.assigned_target.unwrap_or_else(|| "-".into())
); );
} }
Ok(out) Ok(out)
} }
Some("assign-all") => { Some("assign-all") => {
let target = args.get(1).ok_or_else(|| { let target = args
"usage: gate assign-all <iso_boot_entry_id>".to_string() .get(1)
})?; .ok_or_else(|| "usage: queue assign-all <iso_boot_entry_id>".to_string())?;
let found = s let found = s
.iso_store .iso_store
.list() .list()
@@ -219,30 +234,32 @@ async fn gate_command(s: &AppState, args: &[String]) -> Result<String, String> {
} }
let ids: Vec<_> = s.queue.list().into_iter().map(|g| g.id).collect(); let ids: Vec<_> = s.queue.list().into_iter().map(|g| g.id).collect();
let n = s.queue.assign(&ids, target); let n = s.queue.assign(&ids, target);
Ok(format!("assigned {n} gates -> {target}")) Ok(format!("assigned {n} queue entries -> {target}"))
} }
Some("assign") => { Some("assign") => {
let entry_id = args let entry_id = args
.get(1) .get(1)
.ok_or_else(|| "usage: gate assign <entry_id> <iso_boot_entry_id>".to_string())?; .ok_or_else(|| "usage: queue assign <entry_id> <iso_boot_entry_id>".to_string())?;
let target = args let target = args
.get(2) .get(2)
.ok_or_else(|| "usage: gate assign <entry_id> <iso_boot_entry_id>".to_string())?; .ok_or_else(|| "usage: queue assign <entry_id> <iso_boot_entry_id>".to_string())?;
let n = s.queue.assign(std::slice::from_ref(entry_id), target); let n = s.queue.assign(std::slice::from_ref(entry_id), target);
if n == 0 { if n == 0 {
return Err(format!("no such gate: {entry_id}")); return Err(format!("no such queue entry: {entry_id}"));
} }
Ok(format!("assigned 1 gate -> {target}")) Ok(format!("assigned 1 queue entry -> {target}"))
} }
Some("release") => { Some("release") => {
let entry_id = args.get(1).ok_or_else(|| "usage: gate release <entry_id>".to_string())?; let entry_id = args
.get(1)
.ok_or_else(|| "usage: queue release <entry_id>".to_string())?;
match s.queue.release(entry_id) { match s.queue.release(entry_id) {
Some(_) => Ok(format!("released {entry_id}")), Some(_) => Ok(format!("released {entry_id}")),
None => Err(format!("no such gate: {entry_id}")), None => Err(format!("no such queue entry: {entry_id}")),
} }
} }
Some(other) => Err(format!( Some(other) => Err(format!(
"unknown gate subcommand: {other}\ntry: gate [list|assign-all|assign|release]" "unknown queue subcommand: {other}\ntry: queue [list|assign-all|assign|release]"
)), )),
} }
} }
@@ -294,7 +311,9 @@ async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
let version = match args.get(2).map(String::as_str) { let version = match args.get(2).map(String::as_str) {
Some("v3") => openpxe_iso_store::NfsVersion::V3, Some("v3") => openpxe_iso_store::NfsVersion::V3,
Some("v41") | None => openpxe_iso_store::NfsVersion::V41, Some("v41") | None => openpxe_iso_store::NfsVersion::V41,
Some(other) => return Err(format!("unknown nfs version: {other} (expect v3 or 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 read_only = !matches!(args.get(3).map(String::as_str), Some("rw"));
let req = openpxe_iso_store::NfsAddRequest { let req = openpxe_iso_store::NfsAddRequest {
@@ -309,14 +328,18 @@ async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
} }
} }
Some("unmount") => { Some("unmount") => {
let id = args.get(1).ok_or_else(|| "usage: nfs unmount <id>".to_string())?; let id = args
.get(1)
.ok_or_else(|| "usage: nfs unmount <id>".to_string())?;
match s.nfs.remove(id).await { match s.nfs.remove(id).await {
Ok(()) => Ok(format!("unmounted {id}")), Ok(()) => Ok(format!("unmounted {id}")),
Err(e) => Err(format!("unmount failed: {e}")), Err(e) => Err(format!("unmount failed: {e}")),
} }
} }
Some("scan") => { Some("scan") => {
let id = args.get(1).ok_or_else(|| "usage: nfs scan <id>".to_string())?; let id = args
.get(1)
.ok_or_else(|| "usage: nfs scan <id>".to_string())?;
match s.nfs.rescan(id).await { match s.nfs.rescan(id).await {
Ok(n) => Ok(format!("re-scanned {id}: {n} isos")), Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
Err(e) => Err(format!("scan failed: {e}")), Err(e) => Err(format!("scan failed: {e}")),
@@ -368,10 +391,7 @@ fn log_command(s: &AppState, args: &[String]) -> Result<String, String> {
Ok("log buffer cleared".into()) Ok("log buffer cleared".into())
} }
Some("tail") => { Some("tail") => {
let n: usize = args let n: usize = args.get(1).and_then(|v| v.parse().ok()).unwrap_or(20);
.get(1)
.and_then(|v| v.parse().ok())
.unwrap_or(20);
let lines = s.log_bus.recent(); let lines = s.log_bus.recent();
let start = lines.len().saturating_sub(n); let start = lines.len().saturating_sub(n);
let mut out = String::new(); let mut out = String::new();
@@ -462,10 +482,10 @@ OpenPXE terminal — available commands:
isos list registered ISOs isos list registered ISOs
clients list PXE clients seen this session clients list PXE clients seen this session
gate list list gated-deployment queue queue list list queued clients
gate assign <entry_id> <target> assign one gate to a boot entry queue assign <entry_id> <target> assign one queued client to a boot entry
gate assign-all <target> assign every waiting gate queue assign-all <target> assign every waiting client
gate release <entry_id> release one gate queue release <entry_id> release one queued client
nfs list list NFS mounts nfs list list NFS mounts
nfs mount <s>:<e> [v3|v41] [ro|rw] add and mount an NFS share nfs mount <s>:<e> [v3|v41] [ro|rw] add and mount an NFS share
@@ -521,4 +541,10 @@ mod tests {
assert_eq!(truncate("hi", 10), "hi"); assert_eq!(truncate("hi", 10), "hi");
assert_eq!(truncate("longerthanfive", 5), "long…"); assert_eq!(truncate("longerthanfive", 5), "long…");
} }
#[test]
fn help_uses_queue_language() {
assert!(HELP_TEXT.contains("queue list"));
assert!(HELP_TEXT.contains("queued clients"));
}
} }
+382 -79
View File
@@ -38,13 +38,12 @@ fn fake_alpine_iso() -> Vec<u8> {
} }
fn multipart_iso_body(filename: &str, bytes: &[u8]) -> (String, Vec<u8>) { fn multipart_iso_body(filename: &str, bytes: &[u8]) -> (String, Vec<u8>) {
let boundary = "----PxeForgeTestBoundary1234"; let boundary = "----OpenPxeTestBoundary1234";
let mut body = Vec::new(); let mut body = Vec::new();
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
body.extend_from_slice( body.extend_from_slice(
format!( format!("Content-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\n")
"Content-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\n" .as_bytes(),
).as_bytes(),
); );
body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n"); body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
body.extend_from_slice(bytes); body.extend_from_slice(bytes);
@@ -60,7 +59,10 @@ async fn get(router: &axum::Router, path: &str) -> (StatusCode, Vec<u8>) {
.await .await
.unwrap(); .unwrap();
let status = res.status(); let status = res.status();
let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap().to_vec(); let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap()
.to_vec();
(status, body) (status, body)
} }
@@ -78,7 +80,10 @@ async fn post_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode
.await .await
.unwrap(); .unwrap();
let status = res.status(); let status = res.status();
let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap().to_vec(); let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap()
.to_vec();
(status, body) (status, body)
} }
@@ -87,7 +92,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
let iso_store = IsoStore::new(dir.path().join("isos")); let iso_store = IsoStore::new(dir.path().join("isos"));
iso_store.ensure_dirs().await.unwrap(); iso_store.ensure_dirs().await.unwrap();
let clients = ClientRegistry::new(); let clients = ClientRegistry::new();
let gates = DeploymentQueue::new(); let queue = DeploymentQueue::new();
let settings = SettingsStore::load_or_default(dir.path()); let settings = SettingsStore::load_or_default(dir.path());
let nfs = NfsManager::new(dir.path(), iso_store.clone()); let nfs = NfsManager::new(dir.path(), iso_store.clone());
iso_store.set_nfs_root(nfs.mount_root()); iso_store.set_nfs_root(nfs.mount_root());
@@ -97,7 +102,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
let state = AppState { let state = AppState {
iso_store, iso_store,
clients, clients,
queue: gates, queue,
settings, settings,
hosts, hosts,
metrics, metrics,
@@ -153,13 +158,21 @@ async fn upload_introspects_and_generates_boot_entry() {
// Confirm the ISO shows up in the menu. // Confirm the ISO shows up in the menu.
let (_, menu) = get(&app, "/boot.ipxe").await; let (_, menu) = get(&app, "/boot.ipxe").await;
let menu = String::from_utf8(menu).unwrap(); let menu = String::from_utf8(menu).unwrap();
assert!(menu.contains("Linux Installers"), "menu missing Linux submenu:\n{menu}"); assert!(
menu.contains("Linux Installers"),
"menu missing Linux submenu:\n{menu}"
);
let (_, linux) = get(&app, "/boot/_linux_menu.ipxe").await; let (_, linux) = get(&app, "/boot/_linux_menu.ipxe").await;
let linux = String::from_utf8(linux).unwrap(); let linux = String::from_utf8(linux).unwrap();
assert!(linux.contains("fake-alpine-linux"), "linux submenu missing entry:\n{linux}"); assert!(
assert!(linux.contains("[ 0 MB]") || linux.contains("[ 0 MB]"), linux.contains("fake-alpine-linux"),
"size label missing in {linux}"); "linux submenu missing entry:\n{linux}"
);
assert!(
linux.contains("[ 0 MB]") || linux.contains("[ 0 MB]"),
"size label missing in {linux}"
);
// Per-entry boot script should include kernel + initrd URLs + boot. // Per-entry boot script should include kernel + initrd URLs + boot.
let (_, entry) = get(&app, "/boot/fake-alpine-linux.ipxe").await; let (_, entry) = get(&app, "/boot/fake-alpine-linux.ipxe").await;
@@ -178,9 +191,14 @@ async fn iso_range_request_slices_correctly() {
app.clone() app.clone()
.oneshot( .oneshot(
Request::builder() Request::builder()
.method("POST").uri("/api/isos") .method("POST")
.uri("/api/isos")
.header("content-type", ct) .header("content-type", ct)
.body(Body::from(body)).unwrap()).await.unwrap(); .body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
// Range bytes=0x8000-0x8005 should return the PVD signature byte. // Range bytes=0x8000-0x8005 should return the PVD signature byte.
let res = app let res = app
@@ -189,10 +207,15 @@ async fn iso_range_request_slices_correctly() {
Request::builder() Request::builder()
.uri("/iso/fake-alpine.iso") .uri("/iso/fake-alpine.iso")
.header(header::RANGE, "bytes=32768-32773") .header(header::RANGE, "bytes=32768-32773")
.body(Body::empty()).unwrap()) .body(Body::empty())
.await.unwrap(); .unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT); assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
let slice = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap(); let slice = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(slice[0], 0x01); // PVD type assert_eq!(slice[0], 0x01); // PVD type
assert_eq!(&slice[1..6], b"CD001"); assert_eq!(&slice[1..6], b"CD001");
} }
@@ -205,27 +228,40 @@ async fn queued_deployment_full_flow() {
// Upload an ISO so the target exists. // Upload an ISO so the target exists.
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso()); let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
app.clone() app.clone()
.oneshot(Request::builder().method("POST").uri("/api/isos") .oneshot(
.header("content-type", ct).body(Body::from(body)).unwrap()) Request::builder()
.await.unwrap(); .method("POST")
.uri("/api/isos")
.header("content-type", ct)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
// Two clients join. // Two clients join.
let (_, join1) = get(&app, "/api/queue/join?mac=aa:bb:cc:00:00:01").await; let (_, join1) = get(&app, "/api/queue/join?mac=aa:bb:cc:00:00:01").await;
let (_, join2) = get(&app, "/api/queue/join?mac=aa:bb:cc:00:00:02").await; let (_, join2) = get(&app, "/api/queue/join?mac=aa:bb:cc:00:00:02").await;
let s1 = String::from_utf8(join1).unwrap(); let s1 = String::from_utf8(join1).unwrap();
let s2 = String::from_utf8(join2).unwrap(); let s2 = String::from_utf8(join2).unwrap();
assert!(s1.contains("Gate Position 1")); assert!(s1.contains("Queue Position 1"));
assert!(s2.contains("Gate Position 2")); assert!(s2.contains("Queue Position 2"));
let gate1_id = s1.lines().find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/queue/poll/")) let queue1_id = s1
.unwrap().to_string(); .lines()
let gate2_id = s2.lines().find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/queue/poll/")) .find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/queue/poll/"))
.unwrap().to_string(); .unwrap()
.to_string();
let queue2_id = s2
.lines()
.find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/queue/poll/"))
.unwrap()
.to_string();
// Kick off a long-poll for client 1 in the background. Then assign. // Kick off a long-poll for client 1 in the background. Then assign.
let app2 = app.clone(); let app2 = app.clone();
let poll_future = tokio::spawn(async move { let poll_future = tokio::spawn(async move {
let uri = format!("/api/queue/poll/{gate1_id}"); let uri = format!("/api/queue/poll/{queue1_id}");
get(&app2, &uri).await get(&app2, &uri).await
}); });
@@ -233,13 +269,16 @@ async fn queued_deployment_full_flow() {
tokio::time::sleep(std::time::Duration::from_millis(50)).await; tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// Operator assigns. // Operator assigns.
let body = format!(r#"{{"target":"fake-alpine-linux","entry_ids":["{gate2_id}"]}}"#); let body = format!(r#"{{"target":"fake-alpine-linux","entry_ids":["{queue2_id}"]}}"#);
let (s, b) = post_json(&app, "/api/queue/assign", &body).await; let (s, b) = post_json(&app, "/api/queue/assign", &body).await;
assert_eq!(s, StatusCode::OK); assert_eq!(s, StatusCode::OK);
let assign_json = String::from_utf8(b).unwrap(); let assign_json = String::from_utf8(b).unwrap();
assert!(assign_json.contains(r#""assigned":1"#), "assign response: {assign_json}"); assert!(
assign_json.contains(r#""assigned":1"#),
"assign response: {assign_json}"
);
// Now assign to gate 1 too so the background poll wakes. // Now assign to queue entry 1 too so the background poll wakes.
let body = r#"{"target":"fake-alpine-linux","entry_ids":[]}"#; let body = r#"{"target":"fake-alpine-linux","entry_ids":[]}"#;
post_json(&app, "/api/queue/assign", body).await; post_json(&app, "/api/queue/assign", body).await;
@@ -251,14 +290,23 @@ async fn queued_deployment_full_flow() {
"poll response should chain the boot script:\n{poll_s}" "poll response should chain the boot script:\n{poll_s}"
); );
// Retry-on-error fallback must be present. // Retry-on-error fallback must be present.
assert!(poll_s.contains("|| chain http://127.0.0.1/api/queue/poll/"), assert!(
"retry fallback missing"); poll_s.contains("|| chain http://127.0.0.1/api/queue/poll/"),
"retry fallback missing"
);
// Bad target must be rejected. // Bad target must be rejected.
let (_, bad) = post_json(&app, "/api/queue/assign", let (_, bad) = post_json(
r#"{"target":"does-not-exist","entry_ids":[]}"#).await; &app,
"/api/queue/assign",
r#"{"target":"does-not-exist","entry_ids":[]}"#,
)
.await;
let bad_s = String::from_utf8(bad).unwrap(); let bad_s = String::from_utf8(bad).unwrap();
assert!(bad_s.contains(r#""ok":false"#), "expected rejection: {bad_s}"); assert!(
bad_s.contains(r#""ok":false"#),
"expected rejection: {bad_s}"
);
} }
#[tokio::test] #[tokio::test]
@@ -272,8 +320,9 @@ async fn settings_put_persists_across_reads() {
"smb_host_override": "", "smb_host_override": "",
"extra_kernel_args": "console=ttyS0", "extra_kernel_args": "console=ttyS0",
"default_local_hdd": true, "default_local_hdd": true,
"gate_wait_max_secs": 0 "queue_wait_max_secs": 0
}).to_string(); })
.to_string();
let res = app let res = app
.clone() .clone()
@@ -283,7 +332,8 @@ async fn settings_put_persists_across_reads() {
.uri("/api/settings") .uri("/api/settings")
.header("content-type", "application/json") .header("content-type", "application/json")
.body(Body::from(body)) .body(Body::from(body))
.unwrap()) .unwrap(),
)
.await .await
.unwrap(); .unwrap();
assert_eq!(res.status(), StatusCode::NO_CONTENT); assert_eq!(res.status(), StatusCode::NO_CONTENT);
@@ -297,10 +347,14 @@ async fn settings_put_persists_across_reads() {
// And the menu should now use the new timeout. // And the menu should now use the new timeout.
let (_, menu) = get(&app, "/boot.ipxe").await; let (_, menu) = get(&app, "/boot.ipxe").await;
let menu = String::from_utf8(menu).unwrap(); let menu = String::from_utf8(menu).unwrap();
assert!(menu.contains("--timeout 42000"), assert!(
"menu should reflect 42s timeout:\n{menu}"); menu.contains("--timeout 42000"),
assert!(menu.contains("--default local"), "menu should reflect 42s timeout:\n{menu}"
"menu should default to local:\n{menu}"); );
assert!(
menu.contains("--default local"),
"menu should default to local:\n{menu}"
);
} }
#[tokio::test] #[tokio::test]
@@ -309,14 +363,22 @@ async fn reboot_and_firmware_exit_in_tools_menu() {
let app = build_router(state); let app = build_router(state);
let (_, tools) = get(&app, "/boot/_tools_menu.ipxe").await; let (_, tools) = get(&app, "/boot/_tools_menu.ipxe").await;
let tools = String::from_utf8(tools).unwrap(); let tools = String::from_utf8(tools).unwrap();
assert!(tools.contains("Reboot Computer"), assert!(
"tools menu missing Reboot item:\n{tools}"); tools.contains("Reboot Computer"),
assert!(tools.contains("Exit and continue BIOS boot"), "tools menu missing Reboot item:\n{tools}"
"tools menu missing firmware-exit item:\n{tools}"); );
assert!(tools.contains("&& reboot"), assert!(
"reboot command not wired:\n{tools}"); tools.contains("Exit and continue BIOS boot"),
assert!(tools.contains("&& exit 0"), "tools menu missing firmware-exit item:\n{tools}"
"firmware exit command not wired:\n{tools}"); );
assert!(
tools.contains("&& reboot"),
"reboot command not wired:\n{tools}"
);
assert!(
tools.contains("&& exit 0"),
"firmware exit command not wired:\n{tools}"
);
} }
#[tokio::test] #[tokio::test]
@@ -334,11 +396,15 @@ async fn ui_assets_served_offline() {
let res = app let res = app
.clone() .clone()
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()) .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
.await.unwrap(); .await
.unwrap();
assert_eq!(res.status(), StatusCode::OK, "{path} not 200"); assert_eq!(res.status(), StatusCode::OK, "{path} not 200");
let got = res.headers() let got = res
.get(header::CONTENT_TYPE).unwrap() .headers()
.to_str().unwrap(); .get(header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap();
assert!(got.starts_with(ct), "{path} ct={got}, expected {ct}"); assert!(got.starts_with(ct), "{path} ct={got}, expected {ct}");
} }
} }
@@ -348,20 +414,38 @@ async fn no_external_urls_in_generated_ipxe() {
// Sanity check that nothing we serve points off-server. // Sanity check that nothing we serve points off-server.
let (state, _dir) = build_state().await; let (state, _dir) = build_state().await;
let app = build_router(state); let app = build_router(state);
for path in ["/boot.ipxe", "/boot/_tools_menu.ipxe", "/boot/_linux_menu.ipxe", for path in [
"/boot/_shell.ipxe", "/boot/_nic.ipxe", "/boot/_local.ipxe"] { "/boot.ipxe",
"/boot/_tools_menu.ipxe",
"/boot/_linux_menu.ipxe",
"/boot/_shell.ipxe",
"/boot/_nic.ipxe",
"/boot/_local.ipxe",
] {
let (_, body) = get(&app, path).await; let (_, body) = get(&app, path).await;
let s = String::from_utf8(body).unwrap(); let s = String::from_utf8(body).unwrap();
// The only URLs we should emit are relative to our own public_base_url. // The only URLs we should emit are relative to our own public_base_url.
for url in ["github.com", "googleapis", "cdn.", "cdnjs", "unpkg", "jsdelivr"] { for url in [
assert!(!s.contains(url), "{path} references external host {url}:\n{s}"); "github.com",
"googleapis",
"cdn.",
"cdnjs",
"unpkg",
"jsdelivr",
] {
assert!(
!s.contains(url),
"{path} references external host {url}:\n{s}"
);
} }
// Confirm URLs are all ours. // Confirm URLs are all ours.
for line in s.lines() { for line in s.lines() {
if let Some(idx) = line.find("http://") { if let Some(idx) = line.find("http://") {
let rest = &line[idx..]; let rest = &line[idx..];
assert!(rest.starts_with("http://127.0.0.1"), assert!(
"{path} references non-public-base URL: {line}"); rest.starts_with("http://127.0.0.1"),
"{path} references non-public-base URL: {line}"
);
} }
} }
} }
@@ -384,7 +468,10 @@ async fn nfs_add_with_bad_export_is_rejected() {
.await; .await;
assert_eq!(s, StatusCode::BAD_REQUEST); assert_eq!(s, StatusCode::BAD_REQUEST);
let msg = String::from_utf8_lossy(&b); let msg = String::from_utf8_lossy(&b);
assert!(msg.contains("export"), "expected validation hint, got: {msg}"); assert!(
msg.contains("export"),
"expected validation hint, got: {msg}"
);
} }
#[tokio::test] #[tokio::test]
@@ -413,7 +500,10 @@ async fn terminal_help_and_status_round_trip() {
assert_eq!(s, StatusCode::OK); assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
let out = v["output"].as_str().unwrap(); let out = v["output"].as_str().unwrap();
assert!(out.starts_with("OpenPXE"), "unexpected status output: {out}"); assert!(
out.starts_with("OpenPXE"),
"unexpected status output: {out}"
);
assert!(out.contains("isos:"), "status missing iso line: {out}"); assert!(out.contains("isos:"), "status missing iso line: {out}");
// Unknown command -> ok=false plus help hint. // Unknown command -> ok=false plus help hint.
@@ -434,7 +524,10 @@ async fn log_recent_returns_buffered_lines() {
assert_eq!(s, StatusCode::OK); assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
let lines = v["lines"].as_array().expect("lines array"); let lines = v["lines"].as_array().expect("lines array");
assert!(!lines.is_empty(), "log buffer should have at least one line"); assert!(
!lines.is_empty(),
"log buffer should have at least one line"
);
// Every entry should have the canonical timestamp/level/target/message. // Every entry should have the canonical timestamp/level/target/message.
for l in lines { for l in lines {
for k in ["timestamp", "level", "target", "message"] { for k in ["timestamp", "level", "target", "message"] {
@@ -479,7 +572,7 @@ async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
.body(Body::from( .body(Body::from(
r#"{"boot_menu_timeout_secs":600,"timeout_action":"queued_deployment", r#"{"boot_menu_timeout_secs":600,"timeout_action":"queued_deployment",
"windows_enabled":false,"smb_host_override":"","extra_kernel_args":"", "windows_enabled":false,"smb_host_override":"","extra_kernel_args":"",
"default_local_hdd":true,"gate_wait_max_secs":0,"dns_server":""}"# "default_local_hdd":true,"queue_wait_max_secs":0,"dns_server":""}"#
.to_string(), .to_string(),
)) ))
.unwrap(), .unwrap(),
@@ -504,7 +597,9 @@ async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
.await .await
.unwrap(); .unwrap();
assert_eq!(res.status(), StatusCode::CREATED); assert_eq!(res.status(), StatusCode::CREATED);
let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap(); let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
let meta: serde_json::Value = serde_json::from_slice(&body).unwrap(); let meta: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(meta["introspection"]["family"], "windows_pe"); assert_eq!(meta["introspection"]["family"], "windows_pe");
assert!( assert!(
@@ -534,7 +629,10 @@ async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
assert_eq!(s, StatusCode::OK); assert_eq!(s, StatusCode::OK);
let script = String::from_utf8(body).unwrap(); let script = String::from_utf8(body).unwrap();
assert!(script.contains("kernel "), "missing kernel line:\n{script}"); assert!(script.contains("kernel "), "missing kernel line:\n{script}");
assert!(script.contains("ipxe/wimboot"), "missing wimboot loader:\n{script}"); assert!(
script.contains("ipxe/wimboot"),
"missing wimboot loader:\n{script}"
);
for tag in ["bootmgr", "bootmgr.efi", "bcd", "boot.sdi", "boot.wim"] { for tag in ["bootmgr", "bootmgr.efi", "bcd", "boot.sdi", "boot.wim"] {
assert!( assert!(
script.contains(&format!("initrd --name {tag}")), script.contains(&format!("initrd --name {tag}")),
@@ -544,8 +642,12 @@ async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
// Hard guarantees we never want to see in any client-facing script. // Hard guarantees we never want to see in any client-facing script.
let lower = script.to_lowercase(); let lower = script.to_lowercase();
for forbidden in [ for forbidden in [
"bcdedit", "testsigning", "certutil", "test-signed", "bcdedit",
"httpdisk", "/set testsigning", "testsigning",
"certutil",
"test-signed",
"httpdisk",
"/set testsigning",
] { ] {
assert!( assert!(
!lower.contains(forbidden), !lower.contains(forbidden),
@@ -610,16 +712,25 @@ async fn metrics_endpoint_emits_prometheus_format() {
let res = app let res = app
.clone() .clone()
.oneshot(Request::builder().uri("/metrics").body(Body::empty()).unwrap()) .oneshot(
Request::builder()
.uri("/metrics")
.body(Body::empty())
.unwrap(),
)
.await .await
.unwrap(); .unwrap();
assert_eq!(res.status(), StatusCode::OK); assert_eq!(res.status(), StatusCode::OK);
let ct = res.headers().get(header::CONTENT_TYPE).unwrap().to_str().unwrap(); let ct = res
assert!( .headers()
ct.starts_with("text/plain"), .get(header::CONTENT_TYPE)
"wrong content-type: {ct}" .unwrap()
); .to_str()
let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap(); .unwrap();
assert!(ct.starts_with("text/plain"), "wrong content-type: {ct}");
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8(body.to_vec()).unwrap(); let body = String::from_utf8(body.to_vec()).unwrap();
// Spot-check the must-have metric families. // Spot-check the must-have metric families.
for name in [ for name in [
@@ -633,10 +744,7 @@ async fn metrics_endpoint_emits_prometheus_format() {
assert!(body.contains(name), "missing metric {name} in:\n{body}"); assert!(body.contains(name), "missing metric {name} in:\n{body}");
} }
// Each name appears exactly once as a `# TYPE` declaration. // Each name appears exactly once as a `# TYPE` declaration.
for name in [ for name in ["openpxe_dhcp_replies_total", "openpxe_iso_count"] {
"openpxe_dhcp_replies_total",
"openpxe_iso_count",
] {
let count = body.matches(&format!("# TYPE {name}")).count(); let count = body.matches(&format!("# TYPE {name}")).count();
assert_eq!(count, 1, "{name} TYPE line appears {count} times"); assert_eq!(count, 1, "{name} TYPE line appears {count} times");
} }
@@ -672,3 +780,198 @@ async fn network_endpoint_exposes_dns_round_trip() {
let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["dns_server"], "10.0.0.1"); assert_eq!(v["dns_server"], "10.0.0.1");
} }
// ─── Per-ISO password prompt ──────────────────────────────────────────────
#[tokio::test]
async fn iso_password_prompt_blocks_until_correct_token() {
let (state, _dir) = build_state().await;
let app = build_router(state);
// Upload a synthetic Alpine ISO so we have a real boot entry id to
// protect. Upload filename "fake-alpine.iso" -> id "fake-alpine",
// boot entry id "fake-alpine-linux".
let iso = fake_alpine_iso();
let (ct, body) = multipart_iso_body("fake-alpine.iso", &iso);
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/isos")
.header("content-type", ct)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::CREATED);
// 1. With NO password set, /boot/<id>.ipxe returns the boot script
// immediately and the lock indicator is NOT in the menu.
let (_, body) = get(&app, "/boot/fake-alpine-linux.ipxe").await;
let s = String::from_utf8(body).unwrap();
assert!(s.contains("kernel "), "expected boot script, got:\n{s}");
let (_, lm) = get(&app, "/boot/_linux_menu.ipxe").await;
let lm = String::from_utf8(lm).unwrap();
assert!(lm.contains("fake-alpine-linux"));
assert!(
!lm.contains("fake-alpine-linux *["),
"expected no lock marker in menu before password set:\n{lm}"
);
// 2. Set a password.
let res = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/isos/fake-alpine/password")
.header("content-type", "application/json")
.body(Body::from(r#"{"password":"hunter2"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NO_CONTENT);
// The menu now shows the lock marker (`*` prefix on the size box).
let (_, lm) = get(&app, "/boot/_linux_menu.ipxe").await;
let lm = String::from_utf8(lm).unwrap();
assert!(
lm.contains("fake-alpine-linux *["),
"expected lock marker in menu after password set:\n{lm}"
);
// 3. Without a token, /boot/<id>.ipxe now returns the password
// PROMPT script (read --secret), not the boot script.
let (_, body) = get(&app, "/boot/fake-alpine-linux.ipxe").await;
let s = String::from_utf8(body).unwrap();
assert!(
s.contains("read --secret password"),
"expected prompt script with no token, got:\n{s}"
);
assert!(!s.contains("kernel "), "should not include kernel line yet");
// 4. Wrong token -> "Wrong password." script that chains back to the entry.
let (_, body) = get(&app, "/boot/fake-alpine-linux.ipxe?token=wrongpw").await;
let s = String::from_utf8(body).unwrap();
assert!(
s.contains("Wrong password."),
"expected auth-fail script, got:\n{s}"
);
assert!(s.contains("/boot/fake-alpine-linux.ipxe"));
assert!(!s.contains("kernel "));
// Critical: the WRONG token must NEVER be echoed back in the script.
assert!(
!s.contains("wrongpw"),
"wrong token must not appear in response"
);
// 5. Correct token -> real boot script.
let (_, body) = get(&app, "/boot/fake-alpine-linux.ipxe?token=hunter2").await;
let s = String::from_utf8(body).unwrap();
assert!(
s.contains("kernel "),
"expected boot script with correct token, got:\n{s}"
);
// Don't echo the password into the boot script either.
assert!(
!s.contains("hunter2"),
"correct password must not leak into boot script"
);
// 6. Clear the password (DELETE).
let res = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/isos/fake-alpine/password")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NO_CONTENT);
// Boot is open again, no lock indicator.
let (_, body) = get(&app, "/boot/fake-alpine-linux.ipxe").await;
let s = String::from_utf8(body).unwrap();
assert!(
s.contains("kernel "),
"expected boot script after clear, got:\n{s}"
);
let (_, lm) = get(&app, "/boot/_linux_menu.ipxe").await;
let lm = String::from_utf8(lm).unwrap();
assert!(!lm.contains("fake-alpine-linux *["));
}
#[tokio::test]
async fn iso_password_set_then_clear_via_null_body() {
let (state, _dir) = build_state().await;
let app = build_router(state);
// Upload + set + clear via `{"password": null}` (alternative to DELETE).
let iso = fake_alpine_iso();
let (ct, body) = multipart_iso_body("fake-alpine.iso", &iso);
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/isos")
.header("content-type", ct)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::CREATED);
for body in [
r#"{"password":"x"}"#,
r#"{"password":null}"#,
r#"{"password":""}"#,
] {
let res = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/isos/fake-alpine/password")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NO_CONTENT, "body={body}");
}
// After the empty string, the entry should be unprotected.
let (_, b) = get(&app, "/boot/fake-alpine-linux.ipxe").await;
let s = String::from_utf8(b).unwrap();
assert!(
s.contains("kernel "),
"should be unprotected after empty pw, got:\n{s}"
);
}
#[tokio::test]
async fn set_password_for_unknown_iso_returns_404() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let res = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/isos/does-not-exist/password")
.header("content-type", "application/json")
.body(Body::from(r#"{"password":"x"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}
+3 -1
View File
@@ -51,7 +51,9 @@ pub fn asset_slice(name: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
/// Enumerate embedded asset filenames. Useful for startup logging so the /// Enumerate embedded asset filenames. Useful for startup logging so the
/// operator can immediately tell which architectures will work. /// operator can immediately tell which architectures will work.
pub fn list_assets() -> Vec<String> { pub fn list_assets() -> Vec<String> {
IpxeAssets::iter().map(std::borrow::Cow::into_owned).collect() IpxeAssets::iter()
.map(std::borrow::Cow::into_owned)
.collect()
} }
/// Log at startup which iPXE binaries are present and which are missing. /// Log at startup which iPXE binaries are present and which are missing.
+1
View File
@@ -20,6 +20,7 @@ thiserror.workspace = true
anyhow.workspace = true anyhow.workspace = true
sha2.workspace = true sha2.workspace = true
hex.workspace = true hex.workspace = true
bcrypt.workspace = true
uuid.workspace = true uuid.workspace = true
time.workspace = true time.workspace = true
parking_lot.workspace = true parking_lot.workspace = true
+38 -11
View File
@@ -78,7 +78,9 @@ pub fn introspect(path: &Path) -> IntrospectionReport {
let mut haystack = Vec::with_capacity(scan_bytes.min(32 * 1024 * 1024)); let mut haystack = Vec::with_capacity(scan_bytes.min(32 * 1024 * 1024));
while read_total < scan_bytes { while read_total < scan_bytes {
let n = f.read(&mut buf).unwrap_or(0); let n = f.read(&mut buf).unwrap_or(0);
if n == 0 { break; } if n == 0 {
break;
}
haystack.extend_from_slice(&buf[..n]); haystack.extend_from_slice(&buf[..n]);
read_total += n; read_total += n;
} }
@@ -107,8 +109,11 @@ fn family_from_label(label: &str) -> DistroFamily {
let l = label.to_ascii_lowercase(); let l = label.to_ascii_lowercase();
if l.contains("ubuntu") || l.contains("debian") || l.contains("mint") { if l.contains("ubuntu") || l.contains("debian") || l.contains("mint") {
DistroFamily::DebianUbuntu DistroFamily::DebianUbuntu
} else if l.contains("rhel") || l.contains("centos") || l.contains("fedora") } else if l.contains("rhel")
|| l.contains("rocky") || l.contains("alma") || l.contains("centos")
|| l.contains("fedora")
|| l.contains("rocky")
|| l.contains("alma")
{ {
DistroFamily::RhelFedora DistroFamily::RhelFedora
} else if l.contains("suse") || l.contains("opensuse") { } else if l.contains("suse") || l.contains("opensuse") {
@@ -127,17 +132,30 @@ fn family_from_label(label: &str) -> DistroFamily {
fn guess_kernel_initrd(family: DistroFamily) -> (Option<&'static str>, Vec<&'static str>) { fn guess_kernel_initrd(family: DistroFamily) -> (Option<&'static str>, Vec<&'static str>) {
match family { match family {
DistroFamily::DebianUbuntu => (Some("/casper/vmlinuz"), vec!["/casper/initrd"]), DistroFamily::DebianUbuntu => (Some("/casper/vmlinuz"), vec!["/casper/initrd"]),
DistroFamily::RhelFedora => (Some("/images/pxeboot/vmlinuz"), vec!["/images/pxeboot/initrd.img"]), DistroFamily::RhelFedora => (
DistroFamily::OpenSuse => (Some("/boot/x86_64/loader/linux"), vec!["/boot/x86_64/loader/initrd"]), Some("/images/pxeboot/vmlinuz"),
DistroFamily::Arch => (Some("/arch/boot/x86_64/vmlinuz-linux"), vec!["/arch/boot/x86_64/initramfs-linux.img"]), vec!["/images/pxeboot/initrd.img"],
),
DistroFamily::OpenSuse => (
Some("/boot/x86_64/loader/linux"),
vec!["/boot/x86_64/loader/initrd"],
),
DistroFamily::Arch => (
Some("/arch/boot/x86_64/vmlinuz-linux"),
vec!["/arch/boot/x86_64/initramfs-linux.img"],
),
DistroFamily::Alpine => (Some("/boot/vmlinuz-lts"), vec!["/boot/initramfs-lts"]), DistroFamily::Alpine => (Some("/boot/vmlinuz-lts"), vec!["/boot/initramfs-lts"]),
DistroFamily::WindowsPe | DistroFamily::Unknown => (None, Vec::new()), DistroFamily::WindowsPe | DistroFamily::Unknown => (None, Vec::new()),
} }
} }
fn contains_ascii(haystack: &[u8], needle: &[u8]) -> bool { fn contains_ascii(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || haystack.len() < needle.len() { return false; } if needle.is_empty() || haystack.len() < needle.len() {
haystack.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle)) return false;
}
haystack
.windows(needle.len())
.any(|w| w.eq_ignore_ascii_case(needle))
} }
#[cfg(test)] #[cfg(test)]
@@ -146,9 +164,18 @@ mod tests {
#[test] #[test]
fn label_matching() { fn label_matching() {
assert_eq!(family_from_label("Ubuntu 24.04"), DistroFamily::DebianUbuntu); assert_eq!(
assert_eq!(family_from_label("Rocky-9-x86_64-dvd"), DistroFamily::RhelFedora); family_from_label("Ubuntu 24.04"),
assert_eq!(family_from_label("openSUSE-Leap-15.6"), DistroFamily::OpenSuse); DistroFamily::DebianUbuntu
);
assert_eq!(
family_from_label("Rocky-9-x86_64-dvd"),
DistroFamily::RhelFedora
);
assert_eq!(
family_from_label("openSUSE-Leap-15.6"),
DistroFamily::OpenSuse
);
assert_eq!(family_from_label("ARCH_202604"), DistroFamily::Arch); assert_eq!(family_from_label("ARCH_202604"), DistroFamily::Arch);
assert_eq!(family_from_label("weird-custom"), DistroFamily::Unknown); assert_eq!(family_from_label("weird-custom"), DistroFamily::Unknown);
} }
+3 -1
View File
@@ -27,5 +27,7 @@ pub use entry::{BootEntry, BootKind, KernelArgs};
pub use introspect::{DistroFamily, IntrospectionReport}; pub use introspect::{DistroFamily, IntrospectionReport};
pub use nfs::{NfsAddRequest, NfsManager, NfsMount, NfsVersion}; pub use nfs::{NfsAddRequest, NfsManager, NfsMount, NfsVersion};
pub use smb::{extract_windows_iso, SmbManager, SmbState}; pub use smb::{extract_windows_iso, SmbManager, SmbState};
pub use store::{generate_boot_entries_for, slugify_str, IsoMeta, IsoSource, IsoStore, UploadHandle}; pub use store::{
generate_boot_entries_for, slugify_str, IsoMeta, IsoSource, IsoStore, UploadHandle,
};
pub use windows::{WimPatcher, WinPatchState}; pub use windows::{WimPatcher, WinPatchState};
+3 -9
View File
@@ -36,8 +36,8 @@
use crate::introspect::{introspect, IntrospectionReport}; use crate::introspect::{introspect, IntrospectionReport};
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore}; use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
use parking_lot::Mutex;
use openpxe_core::{Error, Result}; use openpxe_core::{Error, Result};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -403,14 +403,8 @@ impl NfsManager {
mount_id: m.id.clone(), mount_id: m.id.clone(),
relative_path: filename.clone(), relative_path: filename.clone(),
}; };
self.iso_store.register_external( self.iso_store
id, .register_external(id, filename, size, report, boot_entries, source);
filename,
size,
report,
boot_entries,
source,
);
count += 1; count += 1;
} }
Ok(count) Ok(count)
+80 -17
View File
@@ -25,11 +25,11 @@
//! Samba), we return `SmbState::SmbdMissing` and the UI surfaces the //! Samba), we return `SmbState::SmbdMissing` and the UI surfaces the
//! gap. No panics, no retries, no silent failure. //! gap. No panics, no retries, no silent failure.
use parking_lot::Mutex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::sync::Arc; use std::sync::Arc;
use parking_lot::Mutex;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", tag = "state")] #[serde(rename_all = "snake_case", tag = "state")]
@@ -72,7 +72,9 @@ impl SmbManager {
/// ISO under `smb_dir/<slug>/` becomes a share named `<slug>`. Returns /// ISO under `smb_dir/<slug>/` becomes a share named `<slug>`. Returns
/// the sorted list. /// the sorted list.
pub fn discover_shares(&self) -> Vec<String> { pub fn discover_shares(&self) -> Vec<String> {
let Ok(rd) = std::fs::read_dir(&self.smb_dir) else { return vec![]; }; let Ok(rd) = std::fs::read_dir(&self.smb_dir) else {
return vec![];
};
let mut out: Vec<String> = rd let mut out: Vec<String> = rd
.flatten() .flatten()
.filter(|e| e.path().is_dir()) .filter(|e| e.path().is_dir())
@@ -130,7 +132,9 @@ impl SmbManager {
let shares = match self.write_conf() { let shares = match self.write_conf() {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
let s = SmbState::Failed { reason: format!("write smb.conf: {e}") }; let s = SmbState::Failed {
reason: format!("write smb.conf: {e}"),
};
*self.state.lock() = s.clone(); *self.state.lock() = s.clone();
return s; return s;
} }
@@ -139,7 +143,8 @@ impl SmbManager {
.args([ .args([
"--foreground", "--foreground",
"--no-process-group", "--no-process-group",
"--configfile", self.conf_path.to_str().unwrap_or(""), "--configfile",
self.conf_path.to_str().unwrap_or(""),
"--log-stdout", "--log-stdout",
]) ])
.stdin(Stdio::null()) .stdin(Stdio::null())
@@ -156,7 +161,9 @@ impl SmbManager {
s s
} }
Err(e) => { Err(e) => {
let s = SmbState::Failed { reason: format!("spawn smbd: {e}") }; let s = SmbState::Failed {
reason: format!("spawn smbd: {e}"),
};
*self.state.lock() = s.clone(); *self.state.lock() = s.clone();
s s
} }
@@ -168,11 +175,15 @@ impl SmbManager {
#[allow(unsafe_code)] #[allow(unsafe_code)]
pub fn reconcile(&self) -> SmbState { pub fn reconcile(&self) -> SmbState {
let mut g = self.child.lock(); let mut g = self.child.lock();
if g.is_none() { return self.state.lock().clone(); } if g.is_none() {
return self.state.lock().clone();
}
let shares = match self.write_conf() { let shares = match self.write_conf() {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
let s = SmbState::Failed { reason: format!("write smb.conf: {e}") }; let s = SmbState::Failed {
reason: format!("write smb.conf: {e}"),
};
*self.state.lock() = s.clone(); *self.state.lock() = s.clone();
return s; return s;
} }
@@ -191,8 +202,13 @@ impl SmbManager {
// covers this is `nix`, which pulls ~40 transitive deps for a // covers this is `nix`, which pulls ~40 transitive deps for a
// single signal send. One documented unsafe call is the better // single signal send. One documented unsafe call is the better
// tradeoff for a container-first project. // tradeoff for a container-first project.
unsafe { libc::kill(pid, libc::SIGHUP); } unsafe {
let s = SmbState::Running { pid: pid as u32, shares }; libc::kill(pid, libc::SIGHUP);
}
let s = SmbState::Running {
pid: pid as u32,
shares,
};
*self.state.lock() = s.clone(); *self.state.lock() = s.clone();
s s
} else { } else {
@@ -212,15 +228,19 @@ impl SmbManager {
} }
fn smbd_present() -> bool { fn smbd_present() -> bool {
let Ok(paths) = std::env::var("PATH") else { return false; }; let Ok(paths) = std::env::var("PATH") else {
return false;
};
for dir in std::env::split_paths(&paths) { for dir in std::env::split_paths(&paths) {
if dir.join("smbd").is_file() { return true; } if dir.join("smbd").is_file() {
return true;
}
} }
false false
} }
const SMB_CONF_GLOBAL: &str = r"[global] const SMB_CONF_GLOBAL: &str = r"[global]
workgroup = PXEFORGE workgroup = OPENPXE
server min protocol = SMB2 server min protocol = SMB2
smb ports = 445 smb ports = 445
log level = 1 log level = 1
@@ -235,6 +255,15 @@ lock directory = /tmp
state directory = /tmp state directory = /tmp
cache directory = /tmp cache directory = /tmp
pid directory = /tmp pid directory = /tmp
# WinPE reconnect hardening. Windows Setup can reboot mid-install and
# reconnect from the same IP; stale sessions/oplocks otherwise cause
# intermittent `net use` failures on the second stage.
reset on zero vc = yes
oplocks = no
kernel oplocks = no
level2 oplocks = no
strict locking = no
deadtime = 1
"; ";
/// Extract a Windows ISO at `iso_path` into `smb_dir/<slug>/`. Uses /// Extract a Windows ISO at `iso_path` into `smb_dir/<slug>/`. Uses
@@ -245,7 +274,11 @@ pid directory = /tmp
/// Idempotent: if the target dir already contains `sources/boot.wim`, we /// Idempotent: if the target dir already contains `sources/boot.wim`, we
/// skip extraction. Callers who want a forced re-extract should remove the /// skip extraction. Callers who want a forced re-extract should remove the
/// dir first. /// dir first.
pub fn extract_windows_iso(iso_path: &Path, smb_dir: &Path, slug: &str) -> std::io::Result<PathBuf> { pub fn extract_windows_iso(
iso_path: &Path,
smb_dir: &Path,
slug: &str,
) -> std::io::Result<PathBuf> {
let target = smb_dir.join(slug); let target = smb_dir.join(slug);
if target.join("sources").join("boot.wim").is_file() { if target.join("sources").join("boot.wim").is_file() {
tracing::debug!(target: "openpxe::smb", slug, "ISO already extracted, skipping"); tracing::debug!(target: "openpxe::smb", slug, "ISO already extracted, skipping");
@@ -263,7 +296,9 @@ pub fn extract_windows_iso(iso_path: &Path, smb_dir: &Path, slug: &str) -> std::
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.output()?; .output()?;
if out.status.success() { return Ok(target); } if out.status.success() {
return Ok(target);
}
tracing::warn!( tracing::warn!(
target: "openpxe::smb", target: "openpxe::smb",
stderr=%String::from_utf8_lossy(&out.stderr), stderr=%String::from_utf8_lossy(&out.stderr),
@@ -278,7 +313,9 @@ pub fn extract_windows_iso(iso_path: &Path, smb_dir: &Path, slug: &str) -> std::
.args(["-C"]) .args(["-C"])
.arg(&target) .arg(&target)
.output()?; .output()?;
if out.status.success() { return Ok(target); } if out.status.success() {
return Ok(target);
}
return Err(std::io::Error::other(format!( return Err(std::io::Error::other(format!(
"bsdtar failed: {}", "bsdtar failed: {}",
String::from_utf8_lossy(&out.stderr) String::from_utf8_lossy(&out.stderr)
@@ -294,7 +331,9 @@ fn which(cmd: &str) -> Option<PathBuf> {
let paths = std::env::var_os("PATH")?; let paths = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&paths) { for dir in std::env::split_paths(&paths) {
let p = dir.join(cmd); let p = dir.join(cmd);
if p.is_file() { return Some(p); } if p.is_file() {
return Some(p);
}
} }
None None
} }
@@ -320,7 +359,9 @@ mod tests {
let m = SmbManager::new(dir.path().into()); let m = SmbManager::new(dir.path().into());
let st = m.start(); let st = m.start();
// Restore PATH before asserting so any subsequent failure is legible. // Restore PATH before asserting so any subsequent failure is legible.
if let Some(p) = saved { std::env::set_var("PATH", p); } if let Some(p) = saved {
std::env::set_var("PATH", p);
}
assert_eq!(st, SmbState::SmbdMissing); assert_eq!(st, SmbState::SmbdMissing);
} }
@@ -347,5 +388,27 @@ mod tests {
assert!(conf.contains("guest ok = yes")); assert!(conf.contains("guest ok = yes"));
assert!(conf.contains("read only = yes")); assert!(conf.contains("read only = yes"));
assert!(conf.contains("server min protocol = SMB2")); assert!(conf.contains("server min protocol = SMB2"));
assert!(conf.contains("workgroup = OPENPXE"));
}
#[test]
fn write_conf_includes_winpe_reconnect_tuning() {
let dir = tempdir().unwrap();
let m = SmbManager::new(dir.path().into());
m.write_conf().unwrap();
let conf = std::fs::read_to_string(dir.path().join("smb.conf")).unwrap();
for expected in [
"reset on zero vc = yes",
"oplocks = no",
"kernel oplocks = no",
"level2 oplocks = no",
"strict locking = no",
"deadtime = 1",
] {
assert!(
conf.contains(expected),
"missing Windows reconnect Samba option {expected} in:\n{conf}"
);
}
} }
} }
+197 -11
View File
@@ -3,8 +3,8 @@
use crate::entry::{BootEntry, BootKind, KernelArgs}; use crate::entry::{BootEntry, BootKind, KernelArgs};
use crate::introspect::{introspect, DistroFamily, IntrospectionReport}; use crate::introspect::{introspect, DistroFamily, IntrospectionReport};
use bytes::Bytes; use bytes::Bytes;
use parking_lot::RwLock;
use openpxe_core::{Error, Result}; use openpxe_core::{Error, Result};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::collections::HashMap; use std::collections::HashMap;
@@ -48,6 +48,25 @@ pub struct IsoMeta {
/// Old `meta.json` files without this field deserialize as `Local`. /// Old `meta.json` files without this field deserialize as `Local`.
#[serde(default)] #[serde(default)]
pub source: IsoSource, pub source: IsoSource,
/// Optional bcrypt hash of an operator-set password. When present,
/// `/boot/<entry>.ipxe` returns a `read --secret` prompt instead of
/// the boot script until the client chains back with the correct
/// `?token=...`. We never store, log, or transmit the plaintext.
/// Skipped on serialize when None to keep meta.json clean for
/// the common no-password case.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password_hash: Option<String>,
}
impl IsoMeta {
/// Convenience predicate the HTTP layer + UI can both use.
#[must_use]
pub fn is_password_protected(&self) -> bool {
self.password_hash
.as_deref()
.map(str::trim)
.is_some_and(|h| !h.is_empty())
}
} }
pub struct UploadHandle { pub struct UploadHandle {
@@ -94,6 +113,7 @@ impl UploadHandle {
introspection, introspection,
boot_entries, boot_entries,
source: IsoSource::Local, source: IsoSource::Local,
password_hash: None,
}; };
store.persist_meta(&meta).await?; store.persist_meta(&meta).await?;
store.insert(meta.clone()); store.insert(meta.clone());
@@ -150,7 +170,9 @@ impl IsoStore {
let mut entries = tokio::fs::read_dir(self.iso_dir.as_path()).await?; let mut entries = tokio::fs::read_dir(self.iso_dir.as_path()).await?;
while let Some(e) = entries.next_entry().await? { while let Some(e) = entries.next_entry().await? {
let p = e.path(); let p = e.path();
if p.extension().and_then(|s| s.to_str()) != Some("json") { continue; } if p.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
if !p if !p
.file_name() .file_name()
.and_then(|s| s.to_str()) .and_then(|s| s.to_str())
@@ -256,7 +278,10 @@ impl IsoStore {
/// share or unmount the NFS share entirely. /// share or unmount the NFS share entirely.
pub async fn delete(&self, id: &str) -> Result<()> { pub async fn delete(&self, id: &str) -> Result<()> {
let meta = self.get(id); let meta = self.get(id);
let is_local = matches!(meta.as_ref().map(|m| &m.source), Some(IsoSource::Local) | None); let is_local = matches!(
meta.as_ref().map(|m| &m.source),
Some(IsoSource::Local) | None
);
if is_local { if is_local {
let iso = self.iso_path(id); let iso = self.iso_path(id);
let meta_path = self.meta_path(id); let meta_path = self.meta_path(id);
@@ -289,6 +314,7 @@ impl IsoStore {
introspection, introspection,
boot_entries, boot_entries,
source, source,
password_hash: None,
}; };
self.inner.write().isos.insert(id, meta); self.inner.write().isos.insert(id, meta);
} }
@@ -298,9 +324,69 @@ impl IsoStore {
/// to clean out stale entries. /// to clean out stale entries.
pub fn drop_external_source(&self, mount_id: &str) { pub fn drop_external_source(&self, mount_id: &str) {
let mut g = self.inner.write(); let mut g = self.inner.write();
g.isos.retain(|_, m| { g.isos.retain(
!matches!(&m.source, IsoSource::Nfs { mount_id: mid, .. } if mid == mount_id) |_, m| !matches!(&m.source, IsoSource::Nfs { mount_id: mid, .. } if mid == mount_id),
}); );
}
/// Set or clear an ISO's boot password.
///
/// `Some("plaintext")` hashes via bcrypt (cost 10 — fast enough for
/// an interactive iPXE prompt, slow enough to be hostile to brute
/// force on a leaked meta.json) and persists.
///
/// `None` removes the password — the next /boot/<id>.ipxe request
/// returns the script directly without a prompt.
///
/// We never store, log, or transmit the plaintext.
pub async fn set_password(&self, id: &str, password: Option<&str>) -> Result<()> {
let new_hash = match password {
None => None,
Some(pw) => {
let pw = pw.trim();
if pw.is_empty() {
None
} else {
let h = bcrypt::hash(pw, bcrypt::DEFAULT_COST)
.map_err(|e| Error::Other(e.into()))?;
Some(h)
}
}
};
// Update in-memory + grab a clone for persistence outside the lock.
let updated = {
let mut g = self.inner.write();
let m = g
.isos
.get_mut(id)
.ok_or_else(|| Error::Invalid(format!("no such iso '{id}'")))?;
m.password_hash = new_hash;
m.clone()
};
// NFS-sourced ISOs have no on-disk meta.json — skip persistence
// for them (the password lives in memory until the manager
// re-scans the share, then it's gone). Document this in the API
// handler so the operator knows.
if matches!(updated.source, IsoSource::Local) {
self.persist_meta(&updated).await?;
}
Ok(())
}
/// Verify a candidate password against the stored bcrypt hash.
/// Returns:
/// - `Ok(true)` — match (or the ISO has no password set; boot is open)
/// - `Ok(false)` — mismatch
/// - `Err(_)` — id not found, or bcrypt error
pub fn verify_password(&self, id: &str, candidate: &str) -> Result<bool> {
let meta = self
.get(id)
.ok_or_else(|| Error::Invalid(format!("no such iso '{id}'")))?;
let Some(hash) = meta.password_hash else {
return Ok(true); // no password set — anyone can boot
};
bcrypt::verify(candidate, &hash).map_err(|e| Error::Other(e.into()))
} }
} }
@@ -346,7 +432,10 @@ pub fn generate_boot_entries_for(
/// Build `BootEntry`s from the introspection report. URLs are relative — /// Build `BootEntry`s from the introspection report. URLs are relative —
/// the HTTP layer rewrites them with the public base URL per request. /// the HTTP layer rewrites them with the public base URL per request.
fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> Vec<BootEntry> { fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> Vec<BootEntry> {
let title = r.volume_label.clone().unwrap_or_else(|| filename.to_string()); let title = r
.volume_label
.clone()
.unwrap_or_else(|| filename.to_string());
match r.family { match r.family {
DistroFamily::WindowsPe if r.has_boot_wim => { DistroFamily::WindowsPe if r.has_boot_wim => {
// Standard wimboot chain. Paths are in-ISO; the HTTP layer maps // Standard wimboot chain. Paths are in-ISO; the HTTP layer maps
@@ -370,12 +459,22 @@ fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> V
fam if r.kernel_path.is_some() => { fam if r.kernel_path.is_some() => {
let base = format!("iso/{id}"); let base = format!("iso/{id}");
let kernel_url = format!("{base}{}", r.kernel_path.as_deref().unwrap_or("")); let kernel_url = format!("{base}{}", r.kernel_path.as_deref().unwrap_or(""));
let initrd_urls = r.initrd_paths.iter().map(|p| format!("{base}{p}")).collect(); let initrd_urls = r
let args = KernelArgs { cmdline: linux_cmdline(fam, id) }; .initrd_paths
.iter()
.map(|p| format!("{base}{p}"))
.collect();
let args = KernelArgs {
cmdline: linux_cmdline(fam, id),
};
vec![BootEntry { vec![BootEntry {
id: format!("{id}-linux"), id: format!("{id}-linux"),
title, title,
kind: BootKind::LinuxKernel { kernel_url, initrd_urls, args }, kind: BootKind::LinuxKernel {
kernel_url,
initrd_urls,
args,
},
}] }]
} }
_ => { _ => {
@@ -384,7 +483,9 @@ fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> V
vec![BootEntry { vec![BootEntry {
id: format!("{id}-sanboot"), id: format!("{id}-sanboot"),
title: format!("{title} (SAN boot — may fail for >1GiB ISOs)"), title: format!("{title} (SAN boot — may fail for >1GiB ISOs)"),
kind: BootKind::SanBootIso { iso_url: format!("iso/{id}.iso") }, kind: BootKind::SanBootIso {
iso_url: format!("iso/{id}.iso"),
},
}] }]
} }
} }
@@ -416,6 +517,8 @@ fn linux_cmdline(family: DistroFamily, id: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::introspect::{DistroFamily, IntrospectionReport};
use tempfile::tempdir;
#[test] #[test]
fn slugify_basic() { fn slugify_basic() {
@@ -427,4 +530,87 @@ mod tests {
// If a path sneaks in, file_stem strips the directory — OK, not a hazard. // If a path sneaks in, file_stem strips the directory — OK, not a hazard.
assert_eq!(slugify("/etc/passwd"), "passwd"); assert_eq!(slugify("/etc/passwd"), "passwd");
} }
fn fake_meta(id: &str) -> IsoMeta {
IsoMeta {
id: id.into(),
filename: format!("{id}.iso"),
size_bytes: 0,
sha256_hex: None,
uploaded_at: OffsetDateTime::now_utc(),
introspection: IntrospectionReport {
family: DistroFamily::Unknown,
volume_label: None,
kernel_path: None,
initrd_paths: vec![],
has_boot_wim: false,
},
boot_entries: vec![],
source: IsoSource::Local,
password_hash: None,
}
}
#[tokio::test]
async fn password_round_trip_set_verify_clear() {
let dir = tempdir().unwrap();
let store = IsoStore::new(dir.path().to_path_buf());
store.ensure_dirs().await.unwrap();
store
.inner
.write()
.isos
.insert("alpha".into(), fake_meta("alpha"));
// No password set — verify_password returns Ok(true) for any input.
assert!(store.verify_password("alpha", "anything").unwrap());
assert!(!store.get("alpha").unwrap().is_password_protected());
// Set a password.
store.set_password("alpha", Some("hunter2")).await.unwrap();
let m = store.get("alpha").unwrap();
assert!(m.is_password_protected());
assert!(m.password_hash.unwrap().starts_with("$2"));
// Verify correct + wrong.
assert!(store.verify_password("alpha", "hunter2").unwrap());
assert!(!store.verify_password("alpha", "wrong").unwrap());
assert!(!store.verify_password("alpha", "").unwrap());
// Clear by passing None or an empty string.
store.set_password("alpha", None).await.unwrap();
assert!(!store.get("alpha").unwrap().is_password_protected());
store.set_password("alpha", Some("again")).await.unwrap();
store.set_password("alpha", Some(" ")).await.unwrap();
assert!(!store.get("alpha").unwrap().is_password_protected());
}
#[tokio::test]
async fn set_password_for_unknown_id_errors() {
let dir = tempdir().unwrap();
let store = IsoStore::new(dir.path().to_path_buf());
store.ensure_dirs().await.unwrap();
let r = store.set_password("does-not-exist", Some("pw")).await;
assert!(matches!(r, Err(Error::Invalid(_))));
}
#[tokio::test]
async fn password_persists_via_meta_json_for_local_isos() {
// Hash makes it onto disk so it survives a restart.
let dir = tempdir().unwrap();
let store = IsoStore::new(dir.path().to_path_buf());
store.ensure_dirs().await.unwrap();
let meta = fake_meta("alpha");
store.persist_meta(&meta).await.unwrap();
store.insert(meta);
store.set_password("alpha", Some("s3cret")).await.unwrap();
// Re-load from disk and confirm the hash came back.
let store2 = IsoStore::new(dir.path().to_path_buf());
store2.load_from_disk().await.unwrap();
let reloaded = store2.get("alpha").expect("reloaded");
assert!(reloaded.is_password_protected());
assert!(store2.verify_password("alpha", "s3cret").unwrap());
assert!(!store2.verify_password("alpha", "wrong").unwrap());
}
} }
+50 -13
View File
@@ -54,7 +54,10 @@ pub struct WimPatcher {
impl WimPatcher { impl WimPatcher {
#[must_use] #[must_use]
pub fn new(smb_host: String, smb_share: String) -> Self { pub fn new(smb_host: String, smb_share: String) -> Self {
Self { smb_host, smb_share } Self {
smb_host,
smb_share,
}
} }
/// Apply WinPE patches to `boot.wim` inside `extracted_iso_dir`. Returns /// Apply WinPE patches to `boot.wim` inside `extracted_iso_dir`. Returns
@@ -72,31 +75,40 @@ impl WimPatcher {
let work = match tempfile::tempdir() { let work = match tempfile::tempdir() {
Ok(d) => d, Ok(d) => d,
Err(e) => return WinPatchState::Failed { reason: format!("tempdir: {e}") }, Err(e) => {
return WinPatchState::Failed {
reason: format!("tempdir: {e}"),
}
}
}; };
// Stage the two files we want present at /Windows/System32/. // Stage the two files we want present at /Windows/System32/.
let staging = work.path().join("stage/Windows/System32"); let staging = work.path().join("stage/Windows/System32");
if let Err(e) = std::fs::create_dir_all(&staging) { if let Err(e) = std::fs::create_dir_all(&staging) {
return WinPatchState::Failed { reason: format!("staging mkdir: {e}") }; return WinPatchState::Failed {
reason: format!("staging mkdir: {e}"),
};
} }
if let Err(e) = std::fs::write(staging.join("winpeshl.ini"), WINPESHL_INI) { if let Err(e) = std::fs::write(staging.join("winpeshl.ini"), WINPESHL_INI) {
return WinPatchState::Failed { reason: format!("write winpeshl.ini: {e}") }; return WinPatchState::Failed {
reason: format!("write winpeshl.ini: {e}"),
};
} }
let startnet = render_startnet(&self.smb_host, &self.smb_share); let startnet = render_startnet(&self.smb_host, &self.smb_share);
if let Err(e) = std::fs::write(staging.join("startnet.cmd"), startnet) { if let Err(e) = std::fs::write(staging.join("startnet.cmd"), startnet) {
return WinPatchState::Failed { reason: format!("write startnet.cmd: {e}") }; return WinPatchState::Failed {
reason: format!("write startnet.cmd: {e}"),
};
} }
// Build a wimlib update command file: // Build a wimlib update command file:
// add <stage>/Windows/System32 /Windows/System32 // add <stage>/Windows/System32 /Windows/System32
let update_file = work.path().join("update.cmd"); let update_file = work.path().join("update.cmd");
let update_cmd = format!( let update_cmd = format!("add \"{}\" \"/Windows/System32\"\n", staging.display());
"add \"{}\" \"/Windows/System32\"\n",
staging.display()
);
if let Err(e) = std::fs::write(&update_file, update_cmd) { if let Err(e) = std::fs::write(&update_file, update_cmd) {
return WinPatchState::Failed { reason: format!("write update.cmd: {e}") }; return WinPatchState::Failed {
reason: format!("write update.cmd: {e}"),
};
} }
// Run wimlib-imagex update against image index 2 (WinPE). // Run wimlib-imagex update against image index 2 (WinPE).
@@ -120,7 +132,9 @@ impl WimPatcher {
String::from_utf8_lossy(&o.stderr) String::from_utf8_lossy(&o.stderr)
), ),
}, },
Err(e) => WinPatchState::Failed { reason: format!("spawn wimlib-imagex: {e}") }, Err(e) => WinPatchState::Failed {
reason: format!("spawn wimlib-imagex: {e}"),
},
} }
} }
} }
@@ -133,7 +147,9 @@ fn which(cmd: &str) -> Option<PathBuf> {
let paths = std::env::var_os("PATH")?; let paths = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&paths) { for dir in std::env::split_paths(&paths) {
let p = dir.join(cmd); let p = dir.join(cmd);
if p.is_file() { return Some(p); } if p.is_file() {
return Some(p);
}
} }
None None
} }
@@ -174,7 +190,11 @@ fn render_startnet(host: &str, share: &str) -> String {
) )
.unwrap(); .unwrap();
s.push_str(":havenet\r\n"); s.push_str(":havenet\r\n");
writeln!(s, "echo Mapping install media from \\\\{host}\\{share}...\r").unwrap(); writeln!(
s,
"echo Mapping install media from \\\\{host}\\{share}...\r"
)
.unwrap();
writeln!( writeln!(
s, s,
":mapshare\r\nnet use Z: \\\\{host}\\{share} /user:guest \"\" /persistent:no && goto mapped\r\n\ ":mapshare\r\nnet use Z: \\\\{host}\\{share} /user:guest \"\" /persistent:no && goto mapped\r\n\
@@ -205,6 +225,23 @@ mod tests {
assert!(s.contains("setup.exe")); assert!(s.contains("setup.exe"));
} }
#[test]
fn startnet_primes_workstation_and_surfaces_mapping_errors() {
let s = render_startnet("10.0.0.5", "win11");
assert!(
s.contains("net start Workstation"),
"WinPE should explicitly start the SMB client before net use:\n{s}"
);
let net_use_line = s
.lines()
.find(|line| line.contains("net use Z:"))
.expect("net use line");
assert!(
!net_use_line.contains(">nul"),
"net use errors must remain visible in WinPE console: {net_use_line}"
);
}
#[test] #[test]
fn patcher_reports_wimlib_missing_gracefully() { fn patcher_reports_wimlib_missing_gracefully() {
// We don't assume wimlib is present in CI; this checks the missing // We don't assume wimlib is present in CI; this checks the missing
+50 -18
View File
@@ -4,16 +4,16 @@
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use openpxe_core::{ use openpxe_core::{
ClientRegistry, Config, DhcpMode, DeploymentQueue, HostBindings, LogBus, LogBusLayer, Metrics, ClientRegistry, Config, DeploymentQueue, DhcpMode, HostBindings, LogBus, LogBusLayer, Metrics,
SettingsStore, SettingsStore,
}; };
use openpxe_dhcp_proxy::DhcpProxyServer; use openpxe_dhcp_proxy::DhcpProxyServer;
use openpxe_http_api::{build_router, AppState}; use openpxe_http_api::{build_router, AppState};
use openpxe_iso_store::{IsoStore, NfsManager, SmbManager}; use openpxe_iso_store::{IsoStore, NfsManager, SmbManager};
use std::sync::Arc;
use openpxe_tftp::TftpServer; use openpxe_tftp::TftpServer;
use std::net::{Ipv4Addr, SocketAddr}; use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
@@ -39,7 +39,7 @@ enum Command {
/// docker run --rm \ /// docker run --rm \
/// -v /my/isos:/seed:ro \ /// -v /my/isos:/seed:ro \
/// -v openpxe-data:/var/lib/openpxe/isos \ /// -v openpxe-data:/var/lib/openpxe/isos \
/// openpxe:0.1.0 seed --from /seed /// openpxe:0.3.2 seed --from /seed
Seed { Seed {
/// Source directory containing one or more `.iso` files. /// Source directory containing one or more `.iso` files.
#[arg(long)] #[arg(long)]
@@ -100,7 +100,7 @@ async fn main() -> anyhow::Result<()> {
let iso_store = IsoStore::new(config.paths.iso_dir.clone()); let iso_store = IsoStore::new(config.paths.iso_dir.clone());
iso_store.load_from_disk().await?; iso_store.load_from_disk().await?;
let clients = ClientRegistry::new(); let clients = ClientRegistry::new();
let gates = DeploymentQueue::new(); let queue = DeploymentQueue::new();
let settings = SettingsStore::load_or_default(&config.paths.work_dir); let settings = SettingsStore::load_or_default(&config.paths.work_dir);
let hosts = HostBindings::load_or_default(&config.paths.work_dir); let hosts = HostBindings::load_or_default(&config.paths.work_dir);
let metrics = Metrics::new(); let metrics = Metrics::new();
@@ -138,7 +138,7 @@ async fn main() -> anyhow::Result<()> {
iso_store: iso_store.clone(), iso_store: iso_store.clone(),
clients: clients.clone(), clients: clients.clone(),
settings: settings.clone(), settings: settings.clone(),
queue: gates.clone(), queue: queue.clone(),
hosts: hosts.clone(), hosts: hosts.clone(),
metrics: metrics.clone(), metrics: metrics.clone(),
smb: Some(smb.clone()), smb: Some(smb.clone()),
@@ -210,7 +210,11 @@ async fn run_command(cmd: Command, config: Config) -> anyhow::Result<()> {
/// Reuses `IsoStore::begin_upload` / `finish` so the resulting meta on disk /// Reuses `IsoStore::begin_upload` / `finish` so the resulting meta on disk
/// is identical to a web upload — same slug rules, same introspection, same /// is identical to a web upload — same slug rules, same introspection, same
/// sha256. /// sha256.
async fn seed_from_dir(src: &std::path::Path, config: &Config, dry_run: bool) -> anyhow::Result<()> { async fn seed_from_dir(
src: &std::path::Path,
config: &Config,
dry_run: bool,
) -> anyhow::Result<()> {
let store = IsoStore::new(config.paths.iso_dir.clone()); let store = IsoStore::new(config.paths.iso_dir.clone());
store.load_from_disk().await?; store.load_from_disk().await?;
let mut entries = tokio::fs::read_dir(src).await?; let mut entries = tokio::fs::read_dir(src).await?;
@@ -218,7 +222,12 @@ async fn seed_from_dir(src: &std::path::Path, config: &Config, dry_run: bool) ->
let mut skipped = 0u32; let mut skipped = 0u32;
while let Some(entry) = entries.next_entry().await? { while let Some(entry) = entries.next_entry().await? {
let p = entry.path(); let p = entry.path();
if p.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() != Some("iso") { if p.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
!= Some("iso")
{
continue; continue;
} }
let filename = p let filename = p
@@ -226,8 +235,14 @@ async fn seed_from_dir(src: &std::path::Path, config: &Config, dry_run: bool) ->
.and_then(|s| s.to_str()) .and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("non-utf8 filename: {}", p.display()))? .ok_or_else(|| anyhow::anyhow!("non-utf8 filename: {}", p.display()))?
.to_string(); .to_string();
println!(" {} ({} bytes)", filename, tokio::fs::metadata(&p).await?.len()); println!(
if dry_run { continue; } " {} ({} bytes)",
filename,
tokio::fs::metadata(&p).await?.len()
);
if dry_run {
continue;
}
let mut handle = match store.begin_upload(&filename).await { let mut handle = match store.begin_upload(&filename).await {
Ok(h) => h, Ok(h) => h,
@@ -242,15 +257,23 @@ async fn seed_from_dir(src: &std::path::Path, config: &Config, dry_run: bool) ->
let mut buf = vec![0u8; 1024 * 1024]; let mut buf = vec![0u8; 1024 * 1024];
loop { loop {
let n = file.read(&mut buf).await?; let n = file.read(&mut buf).await?;
if n == 0 { break; } if n == 0 {
break;
}
let chunk: bytes::Bytes = buf[..n].to_vec().into(); let chunk: bytes::Bytes = buf[..n].to_vec().into();
handle.write_chunk(&chunk).await?; handle.write_chunk(&chunk).await?;
} }
let meta = handle.finish(&store).await?; let meta = handle.finish(&store).await?;
println!(" -> id={} family={:?}", meta.id, meta.introspection.family); println!(
" -> id={} family={:?}",
meta.id, meta.introspection.family
);
imported += 1; imported += 1;
} }
println!("\nimported={imported} skipped={skipped} {}", if dry_run { "(dry run)" } else { "" }); println!(
"\nimported={imported} skipped={skipped} {}",
if dry_run { "(dry run)" } else { "" }
);
Ok(()) Ok(())
} }
@@ -294,9 +317,8 @@ fn hostname() -> std::io::Result<String> {
if let Ok(h) = std::fs::read_to_string("/proc/sys/kernel/hostname") { if let Ok(h) = std::fs::read_to_string("/proc/sys/kernel/hostname") {
return Ok(h.trim().to_string()); return Ok(h.trim().to_string());
} }
std::env::var("HOSTNAME").map_err(|_| std::io::Error::new( std::env::var("HOSTNAME")
std::io::ErrorKind::NotFound, "no hostname", .map_err(|_| std::io::Error::new(std::io::ErrorKind::NotFound, "no hostname"))
))
} }
fn init_tracing(bus: Arc<LogBus>) { fn init_tracing(bus: Arc<LogBus>) {
@@ -328,7 +350,10 @@ fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo {
// `ip -o -f inet addr show` lists every interface with its // `ip -o -f inet addr show` lists every interface with its
// `inet a.b.c.d/mask`. We match the line that mentions our IP. // `inet a.b.c.d/mask`. We match the line that mentions our IP.
if let Ok(out) = Command::new("ip").args(["-o", "-f", "inet", "addr", "show"]).output() { if let Ok(out) = Command::new("ip")
.args(["-o", "-f", "inet", "addr", "show"])
.output()
{
if let Ok(text) = String::from_utf8(out.stdout) { if let Ok(text) = String::from_utf8(out.stdout) {
for line in text.lines() { for line in text.lines() {
if !line.contains(&our_ip.to_string()) { if !line.contains(&our_ip.to_string()) {
@@ -353,7 +378,10 @@ fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo {
} }
// `ip route show default` -> "default via 10.0.0.1 dev enp1s0 ..." // `ip route show default` -> "default via 10.0.0.1 dev enp1s0 ..."
if let Ok(out) = Command::new("ip").args(["route", "show", "default"]).output() { if let Ok(out) = Command::new("ip")
.args(["route", "show", "default"])
.output()
{
if let Ok(text) = String::from_utf8(out.stdout) { if let Ok(text) = String::from_utf8(out.stdout) {
if let Some(line) = text.lines().next() { if let Some(line) = text.lines().next() {
let mut parts = line.split_whitespace(); let mut parts = line.split_whitespace();
@@ -374,7 +402,11 @@ fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo {
fn prefix_to_dotted(prefix: u8) -> String { fn prefix_to_dotted(prefix: u8) -> String {
let prefix = prefix.min(32); let prefix = prefix.min(32);
let mask: u32 = if prefix == 0 { 0 } else { u32::MAX << (32 - prefix) }; let mask: u32 = if prefix == 0 {
0
} else {
u32::MAX << (32 - prefix)
};
format!( format!(
"{}.{}.{}.{}", "{}.{}.{}.{}",
(mask >> 24) & 0xff, (mask >> 24) & 0xff,
+49 -15
View File
@@ -45,7 +45,12 @@ impl TftpServer {
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics, metrics: openpxe_core::Metrics,
) -> Self { ) -> Self {
Self { bind, port, clients, metrics } Self {
bind,
port,
clients,
metrics,
}
} }
pub async fn run(self) -> anyhow::Result<()> { pub async fn run(self) -> anyhow::Result<()> {
@@ -86,7 +91,9 @@ async fn handle_rrq(
let Some(req) = parse_rrq(&packet) else { let Some(req) = parse_rrq(&packet) else {
return Ok(()); return Ok(());
}; };
let Request { filename, options, .. } = req; let Request {
filename, options, ..
} = req;
// Per-transfer ephemeral socket. // Per-transfer ephemeral socket.
let sock = bind_udp(bind_ip, 0)?; let sock = bind_udp(bind_ip, 0)?;
@@ -98,7 +105,9 @@ async fn handle_rrq(
&peer.ip().to_string(), &peer.ip().to_string(),
Some(peer.ip()), Some(peer.ip()),
None, None,
ClientEvent::TftpRead { file: filename.clone() }, ClientEvent::TftpRead {
file: filename.clone(),
},
); );
return Ok(()); return Ok(());
}; };
@@ -112,7 +121,9 @@ async fn handle_rrq(
&peer.ip().to_string(), &peer.ip().to_string(),
Some(peer.ip()), Some(peer.ip()),
None, None,
ClientEvent::TftpRead { file: filename.clone() }, ClientEvent::TftpRead {
file: filename.clone(),
},
); );
// Negotiate options. // Negotiate options.
@@ -173,7 +184,9 @@ async fn handle_rrq(
// Send one window worth of DATA. // Send one window worth of DATA.
for _ in 0..window { for _ in 0..window {
if offset >= total { break; } if offset >= total {
break;
}
let end = (offset + blksize).min(total); let end = (offset + blksize).min(total);
let chunk = &file_bytes[offset..end]; let chunk = &file_bytes[offset..end];
let pkt = encode_data(block_no, chunk); let pkt = encode_data(block_no, chunk);
@@ -255,20 +268,30 @@ struct Request {
} }
fn parse_rrq(pkt: &[u8]) -> Option<Request> { fn parse_rrq(pkt: &[u8]) -> Option<Request> {
if pkt.len() < 4 { return None; } if pkt.len() < 4 {
return None;
}
let op = u16::from_be_bytes([pkt[0], pkt[1]]); let op = u16::from_be_bytes([pkt[0], pkt[1]]);
if op != OP_RRQ { return None; } if op != OP_RRQ {
return None;
}
let mut rest = &pkt[2..]; let mut rest = &pkt[2..];
let filename = read_cstr(&mut rest)?; let filename = read_cstr(&mut rest)?;
let mode = read_cstr(&mut rest)?; let mode = read_cstr(&mut rest)?;
let mut options = Vec::new(); let mut options = Vec::new();
while !rest.is_empty() { while !rest.is_empty() {
let Some(k) = read_cstr(&mut rest) else { break }; let Some(k) = read_cstr(&mut rest) else { break };
if k.is_empty() { break; } if k.is_empty() {
break;
}
let v = read_cstr(&mut rest).unwrap_or_default(); let v = read_cstr(&mut rest).unwrap_or_default();
options.push((k.to_ascii_lowercase(), v)); options.push((k.to_ascii_lowercase(), v));
} }
Some(Request { filename, mode, options }) Some(Request {
filename,
mode,
options,
})
} }
fn read_cstr(buf: &mut &[u8]) -> Option<String> { fn read_cstr(buf: &mut &[u8]) -> Option<String> {
@@ -316,8 +339,12 @@ async fn recv_ack(sock: &UdpSocket, peer: SocketAddr) -> anyhow::Result<u16> {
let mut buf = [0u8; 32]; let mut buf = [0u8; 32];
loop { loop {
let (n, from) = sock.recv_from(&mut buf).await?; let (n, from) = sock.recv_from(&mut buf).await?;
if from.ip() != peer.ip() { continue; } if from.ip() != peer.ip() {
if n < 4 { continue; } continue;
}
if n < 4 {
continue;
}
let op = u16::from_be_bytes([buf[0], buf[1]]); let op = u16::from_be_bytes([buf[0], buf[1]]);
match op { match op {
OP_ACK => return Ok(u16::from_be_bytes([buf[2], buf[3]])), OP_ACK => return Ok(u16::from_be_bytes([buf[2], buf[3]])),
@@ -344,14 +371,19 @@ async fn wait_for_ack(
Ok(Ok(_)) => {} Ok(Ok(_)) => {}
Ok(Err(_)) | Err(_) => { Ok(Err(_)) | Err(_) => {
tries += 1; tries += 1;
if tries > 5 { return Ok(false); } if tries > 5 {
return Ok(false);
}
} }
} }
} }
} }
fn bind_udp(bind: IpAddr, port: u16) -> anyhow::Result<UdpSocket> { fn bind_udp(bind: IpAddr, port: u16) -> anyhow::Result<UdpSocket> {
let domain = match bind { IpAddr::V4(_) => Domain::IPV4, IpAddr::V6(_) => Domain::IPV6 }; let domain = match bind {
IpAddr::V4(_) => Domain::IPV4,
IpAddr::V6(_) => Domain::IPV6,
};
let sock = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP))?; let sock = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP))?;
sock.set_reuse_address(true)?; sock.set_reuse_address(true)?;
sock.set_nonblocking(true)?; sock.set_nonblocking(true)?;
@@ -382,7 +414,9 @@ pub fn plan_window(
let mut o = offset; let mut o = offset;
let mut b = starting_block; let mut b = starting_block;
for _ in 0..window { for _ in 0..window {
if o >= total { break; } if o >= total {
break;
}
let end = (o + blksize).min(total); let end = (o + blksize).min(total);
out.push((b, end - o)); out.push((b, end - o));
o = end; o = end;
@@ -453,7 +487,7 @@ mod tests {
assert_eq!(p, vec![(65534, 1024), (65535, 1024)]); assert_eq!(p, vec![(65534, 1024), (65535, 1024)]);
let p2 = plan_window(2048, 2048, 1024, 2, 0); let p2 = plan_window(2048, 2048, 1024, 2, 0);
assert!(p2.is_empty()); // nothing past EOF assert!(p2.is_empty()); // nothing past EOF
// And a cross-boundary case: // And a cross-boundary case:
let p3 = plan_window(3072, 0, 1024, 3, 65535); let p3 = plan_window(3072, 0, 1024, 3, 65535);
assert_eq!(p3, vec![(65535, 1024), (0, 1024), (1, 1024)]); assert_eq!(p3, vec![(65535, 1024), (0, 1024), (1, 1024)]);
} }
+2 -3
View File
@@ -281,8 +281,7 @@ label.check input { accent-color: var(--accent); }
/* ── Imaging progress widget ─────────────────────────────────────── /* ── Imaging progress widget ───────────────────────────────────────
Animated brand mark paired with a horizontal progress bar; surfaces Animated brand mark paired with a horizontal progress bar; surfaces
on Dashboard and the Queue tab. Renamed from `.forge-progress` in on Dashboard and the Queue tab. */
v0.3.0 — the anvil-themed naming is gone with the rebrand. */
.queue-progress { .queue-progress {
display: flex; align-items: center; gap: 16px; display: flex; align-items: center; gap: 16px;
padding: 16px; padding: 16px;
@@ -380,7 +379,7 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; } .form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; }
@media (max-width: 900px) { .form-row { grid-template-columns: 1fr; } } @media (max-width: 900px) { .form-row { grid-template-columns: 1fr; } }
/* ── Gate queue "horse race" visual ──────────────────────────────── */ /* ── Queued deployment visual ────────────────────────────────────── */
.queue-track { .queue-track {
display: grid; gap: 6px; display: grid; gap: 6px;
padding: 10px 0; padding: 10px 0;
+111 -15
View File
@@ -246,7 +246,7 @@
return el('div', {class:'grid'}, [networkCard]); return el('div', {class:'grid'}, [networkCard]);
}, },
gate: async () => { queue: async () => {
const [{ entries = [] }, isos] = await Promise.all([ const [{ entries = [] }, isos] = await Promise.all([
getJSON('/api/queue'), getJSON('/api/isos'), getJSON('/api/queue'), getJSON('/api/isos'),
]); ]);
@@ -267,7 +267,7 @@
if (!j.ok) { msg.textContent = 'Assign failed: ' + (j.error || 'unknown'); msg.className='msg err'; return; } if (!j.ok) { msg.textContent = 'Assign failed: ' + (j.error || 'unknown'); msg.className='msg err'; return; }
msg.textContent = 'Launched ' + j.assigned + ' client' + (j.assigned===1?'':'s') + ' → ' + j.target; msg.textContent = 'Launched ' + j.assigned + ' client' + (j.assigned===1?'':'s') + ' → ' + j.target;
msg.className = 'msg ok'; msg.className = 'msg ok';
render('gate'); render('queue');
}; };
const track = entries.length const track = entries.length
@@ -284,12 +284,12 @@
: el('span', {class:'tag accent'}, 'waiting')), : el('span', {class:'tag accent'}, 'waiting')),
el('button', {class:'ghost', onclick: async () => { el('button', {class:'ghost', onclick: async () => {
await fetch('/api/queue/' + encodeURIComponent(g.id), {method:'DELETE'}); await fetch('/api/queue/' + encodeURIComponent(g.id), {method:'DELETE'});
render('gate'); render('queue');
}}, 'Release'), }}, 'Release'),
])) ]))
) )
: el('div', {class:'empty'}, : el('div', {class:'empty'},
'No clients at the gate. Boot a client and choose "Queued Deployment" in the PXE menu.'); 'No clients queued. Boot a client and choose "Queued Deployment" in the PXE menu.');
const imaging = entries.filter(g => g.assigned_target).length; const imaging = entries.filter(g => g.assigned_target).length;
@@ -299,7 +299,7 @@
queueProgressWidget(imaging, entries.length), queueProgressWidget(imaging, entries.length),
]), ]),
el('div', {class:'card'}, [ el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Launch an image across the gate')), el('header', {}, el('h2', {}, 'Launch image for queued clients')),
el('div', {class:'body'}, [ el('div', {class:'body'}, [
el('label', {class:'field'}, [ el('label', {class:'field'}, [
el('span', {class:'name'}, 'Target image'), el('span', {class:'name'}, 'Target image'),
@@ -312,7 +312,7 @@
]), ]),
el('div', {class:'card'}, [ el('div', {class:'card'}, [
el('header', {}, [ el('header', {}, [
el('h2', {}, 'Gate positions'), el('h2', {}, 'Queue positions'),
el('span', {class:'sub'}, entries.length + ' waiting'), el('span', {class:'sub'}, entries.length + ' waiting'),
]), ]),
el('div', {class:'body'}, track), el('div', {class:'body'}, track),
@@ -372,12 +372,99 @@
} }
// ── ISO table (mixed local + NFS) ── // ── ISO table (mixed local + NFS) ──
const rows = isos.map(i => { // Each row gets a "Password" cell that toggles a small inline
// editor (a checkbox + a password field + Save button) inside the
// *next* row of the table. Keeps the markup flat and avoids the
// overhead of a real modal.
const rowsAndEditors = [];
isos.forEach(i => {
const b = bootability(i, settings); const b = bootability(i, settings);
const isNfs = i.source && i.source.kind === 'nfs'; const isNfs = i.source && i.source.kind === 'nfs';
const protectedNow = !!i.password_hash;
// The inline editor row is hidden by default; the Password
// button toggles its `display`. Pre-built so toggle is cheap.
const pwCheck = el('input', {type:'checkbox'});
pwCheck.checked = protectedNow;
const pwInput = el('input', {
type: 'password', spellcheck: 'false',
autocomplete: 'new-password', autocapitalize: 'off',
placeholder: protectedNow ? '(unchanged — type to replace)' : 'choose a password',
});
const pwInputWrap = el('label', {class:'field', style:'flex:1;margin:0'}, [
el('span', {class:'name'}, 'Password'),
pwInput,
]);
// Toggle the password field's visibility off when the checkbox
// is unchecked, so the operator's intent is unambiguous on Save.
const refreshFieldVisibility = () => {
pwInputWrap.style.display = pwCheck.checked ? '' : 'none';
};
pwCheck.onchange = refreshFieldVisibility;
const pwMsg = el('div', {class:'msg', style:'margin-top:6px'});
const pwSave = el('button', {style:'flex:none', onclick: async () => {
let resp;
if (pwCheck.checked) {
// Empty input + previously protected = keep the old password
// (operator just toggled the box on but didn't type). We
// detect this by sending the API only when the field has
// content; otherwise no-op + show hint.
if (!pwInput.value && !protectedNow) {
pwMsg.textContent = 'Enter a password to enable.';
pwMsg.className = 'msg err';
return;
}
if (!pwInput.value && protectedNow) {
pwMsg.textContent = 'Password unchanged.';
pwMsg.className = 'msg ok';
return;
}
resp = await putJSON(
'/api/isos/' + encodeURIComponent(i.id) + '/password',
{ password: pwInput.value });
} else {
resp = await fetch(
'/api/isos/' + encodeURIComponent(i.id) + '/password',
{method: 'DELETE'});
}
if (resp.ok || resp.status === 204) {
// Wipe the input field before re-rendering so the
// plaintext doesn't sit in DOM longer than necessary.
pwInput.value = '';
render('storage');
} else {
const t = await resp.text();
pwMsg.textContent = 'Save failed: ' + t;
pwMsg.className = 'msg err';
}
}}, 'Save password');
const editorCells = el('td', {colspan: '7', style:'background:var(--bg-panel-2);padding:14px 18px'}, [
el('div', {style:'display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap'}, [
el('label', {class:'check', style:'flex:none;margin:0'}, [
pwCheck,
el('span', {}, 'Password protect this image'),
]),
pwInputWrap,
pwSave,
]),
el('div', {class:'msg', style:'margin-top:8px;font-size:11.5px'},
'Operators booting this ISO will be prompted on the PXE client. ' +
'Stored bcrypt-hashed; the plaintext never leaves the request.'),
pwMsg,
]);
const editorRow = el('tr', {style:'display:none'}, editorCells);
refreshFieldVisibility();
const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [ const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [
el('td', {}, [ el('td', {}, [
el('div', {}, i.filename), el('div', {style:'display:flex;align-items:center;gap:8px'}, [
protectedNow ? el('span', {
title: 'Password protected',
style:'color:var(--accent);font-size:13px'
}, '🔒') : null,
el('span', {}, i.filename),
]),
!b.ok ? el('div', {class:'row-warn'}, '⚠ ' + b.reason) !b.ok ? el('div', {class:'row-warn'}, '⚠ ' + b.reason)
: (b.warn ? el('div', {class:'row-warn'}, '⚠ ' + b.warn) : null), : (b.warn ? el('div', {class:'row-warn'}, '⚠ ' + b.warn) : null),
]), ]),
@@ -386,26 +473,35 @@
el('td', {}, el('td', {},
el('span', {class:'src-badge' + (isNfs ? ' nfs' : '')}, el('span', {class:'src-badge' + (isNfs ? ' nfs' : '')},
isNfs ? ('nfs:' + i.source.mount_id) : 'local')), isNfs ? ('nfs:' + i.source.mount_id) : 'local')),
el('td', {},
protectedNow
? el('span', {class:'tag accent'}, 'protected')
: el('span', {class:'tag', style:'opacity:.55'}, 'open')),
el('td', {}, fmtAgo(i.uploaded_at)), el('td', {}, fmtAgo(i.uploaded_at)),
el('td', {style:'text-align:right'}, el('td', {style:'text-align:right;white-space:nowrap'}, [
el('button', {class:'ghost', style:'margin-right:6px', onclick: () => {
editorRow.style.display = (editorRow.style.display === 'none') ? '' : 'none';
}}, protectedNow ? 'Password ✎' : 'Set password'),
isNfs isNfs
? el('span', {class:'tag', style:'opacity:.6'}, 'manage on NFS share') ? el('span', {class:'tag', style:'opacity:.6'}, 'on NFS')
: el('button', {class:'danger', onclick: async () => { : el('button', {class:'danger', onclick: async () => {
if (!confirm('Remove this image?')) return; if (!confirm('Remove this image?')) return;
await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'}); await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'});
render('storage'); render('storage');
}}, 'Remove')), }}, 'Remove'),
]),
]); ]);
return tr; rowsAndEditors.push(tr, editorRow);
}); });
const isoTable = isos.length const isoTable = isos.length
? el('table', {}, [ ? el('table', {}, [
el('thead', {}, el('tr', {}, [ el('thead', {}, el('tr', {}, [
el('th',{},'Name'), el('th',{},'Type'), el('th',{},'Name'), el('th',{},'Type'),
el('th',{class:'num'},'Size'), el('th',{class:'num'},'Size'),
el('th',{},'Source'), el('th',{},'Uploaded'), el('th',{},''), el('th',{},'Source'), el('th',{},'Auth'),
el('th',{},'Uploaded'), el('th',{},''),
])), ])),
el('tbody', {}, rows), el('tbody', {}, rowsAndEditors),
]) ])
: el('div', {class:'empty'}, 'No images yet. Upload an ISO or mount an NFS share.'); : el('div', {class:'empty'}, 'No images yet. Upload an ISO or mount an NFS share.');
@@ -758,7 +854,7 @@
const viewTitles = { const viewTitles = {
dashboard: 'Dashboard', dashboard: 'Dashboard',
network: 'Network', network: 'Network',
gate: 'Queue', queue: 'Queue',
storage: 'Storage', storage: 'Storage',
hosts: 'Hosts', hosts: 'Hosts',
terminal: 'Terminal', terminal: 'Terminal',
+1 -1
View File
@@ -29,7 +29,7 @@
<img src="/assets/logo.svg" alt="" /> <img src="/assets/logo.svg" alt="" />
<div> <div>
<strong>OpenPXE</strong> <strong>OpenPXE</strong>
<div class="sub">v<span data-bind="version">0.3.0</span></div> <div class="sub">v<span data-bind="version">0.3.2</span></div>
</div> </div>
</div> </div>
<nav> <nav>
+17 -9
View File
@@ -15,22 +15,30 @@ pub fn index_html(base_url: &str) -> String {
} }
#[must_use] #[must_use]
pub fn app_js() -> &'static str { APP_JS } pub fn app_js() -> &'static str {
APP_JS
}
#[must_use] #[must_use]
pub fn app_css() -> &'static str { APP_CSS } pub fn app_css() -> &'static str {
APP_CSS
}
#[must_use] #[must_use]
pub fn logo_svg() -> &'static str { LOGO_SVG } pub fn logo_svg() -> &'static str {
LOGO_SVG
}
/// Larger, faster-cycling rainbow disc — used for the page-load /// Larger, faster-cycling rainbow disc — used for the page-load
/// transition and the imaging-progress widget on Dashboard / Queue. /// transition and the imaging-progress widget on Dashboard / Queue.
/// Pure SVG + SMIL, no JS, no GIF. /// Pure SVG + SMIL, no JS, no GIF.
#[must_use] #[must_use]
pub fn loader_svg() -> &'static str { LOADER_SVG } pub fn loader_svg() -> &'static str {
LOADER_SVG
}
const INDEX_HTML: &str = include_str!("index.html"); const INDEX_HTML: &str = include_str!("index.html");
const APP_CSS: &str = include_str!("app.css"); const APP_CSS: &str = include_str!("app.css");
const APP_JS: &str = include_str!("app.js"); const APP_JS: &str = include_str!("app.js");
const LOGO_SVG: &str = include_str!("logo.svg"); const LOGO_SVG: &str = include_str!("logo.svg");
const LOADER_SVG: &str = include_str!("loader.svg"); const LOADER_SVG: &str = include_str!("loader.svg");
+1 -1
View File
@@ -34,7 +34,7 @@ spec:
fsGroup: 10001 fsGroup: 10001
containers: containers:
- name: openpxe - name: openpxe
image: ghcr.io/casperadmin/openpxe:0.1.0 image: gitea.milesward.dev/mward4/openpxe:0.3.2
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- name: dhcp - name: dhcp
+7 -7
View File
@@ -6,7 +6,7 @@ boot from OpenPXE". Pick the one that matches what you have.
## Path A — build on Unraid, push to Gitea registry, pull by tag ## Path A — build on Unraid, push to Gitea registry, pull by tag
Recommended once you've done it once. Image is published to Recommended once you've done it once. Image is published to
`gitea.milesward.dev/mward4/openpxe:0.1.0` (or your equivalent) and `gitea.milesward.dev/mward4/openpxe:0.3.2` (or your equivalent) and
every Unraid template / docker-compose just references the tag. every Unraid template / docker-compose just references the tag.
Pre-flight: Pre-flight:
@@ -40,14 +40,14 @@ What it does:
3. `docker build` against `deploy/docker/Dockerfile`. 3. `docker build` against `deploy/docker/Dockerfile`.
4. `docker login gitea.milesward.dev:3000` using a temp `DOCKER_CONFIG` 4. `docker login gitea.milesward.dev:3000` using a temp `DOCKER_CONFIG`
so the credential never lands in your real `~/.docker/config.json`. so the credential never lands in your real `~/.docker/config.json`.
5. `docker push` both `:0.1.0` and `:latest`. 5. `docker push` both `:0.3.2` and `:latest`.
6. Logout, scrub the temp config, delete the workspace. 6. Logout, scrub the temp config, delete the workspace.
After it finishes, in Unraid → Docker → Add Container, set: After it finishes, in Unraid → Docker → Add Container, set:
| Field | Value | | Field | Value |
|------------|-------------------------------------------------| |------------|-------------------------------------------------|
| Repository | `gitea.milesward.dev/mward4/openpxe:0.1.0` | | Repository | `gitea.milesward.dev/mward4/openpxe:0.3.2` |
| Network | `host` | | Network | `host` |
| Extra args | `--cap-add=NET_BIND_SERVICE` | | Extra args | `--cap-add=NET_BIND_SERVICE` |
@@ -88,14 +88,14 @@ then:
```bash ```bash
# On the build host # On the build host
docker save openpxe:0.1.0 | gzip > openpxe-0.1.0.tar.gz docker save openpxe:0.3.2 | gzip > openpxe-0.3.2.tar.gz
# Transfer (rsync / scp / SMB / ZFS-replicate / sneakernet) # Transfer (rsync / scp / SMB / ZFS-replicate / sneakernet)
scp openpxe-0.1.0.tar.gz root@unraid:/tmp/ scp openpxe-0.3.2.tar.gz root@unraid:/tmp/
# On Unraid # On Unraid
gunzip -c /tmp/openpxe-0.1.0.tar.gz | docker load gunzip -c /tmp/openpxe-0.3.2.tar.gz | docker load
docker tag openpxe:0.1.0 gitea.milesward.dev/mward4/openpxe:0.1.0 docker tag openpxe:0.3.2 gitea.milesward.dev/mward4/openpxe:0.3.2
``` ```
If you want it pullable by tag from other Unraid templates, push to If you want it pullable by tag from other Unraid templates, push to
+2 -2
View File
@@ -34,8 +34,8 @@
Air-gapped network PXE boot server. Container-native Rust Air-gapped network PXE boot server. Container-native Rust
implementation — DHCP proxy + TFTP + iPXE chainload + HTTP ISO implementation — DHCP proxy + TFTP + iPXE chainload + HTTP ISO
streaming, all in one process. Web UI for ISO upload, NFS share streaming, all in one process. Web UI for ISO upload, NFS share
mounting, and Queued Deployment ("horse-race" simultaneous launch mounting, and Queued Deployment for coordinated launch of one ISO
of one ISO across many waiting clients). across many waiting clients.
NEVER touches the client OS trust store: no test-signed drivers, NEVER touches the client OS trust store: no test-signed drivers,
no testsigning toggle, no httpdisk.sys. Windows boot uses vanilla no testsigning toggle, no httpdisk.sys. Windows boot uses vanilla
+2 -2
View File
@@ -1,7 +1,7 @@
# Phase 6 — recommendations # Phase 6 — recommendations
The v0.2.0 cut leaves OpenPXE in a state where the entire protocol stack The v0.3.2 cut leaves OpenPXE in a state where the entire protocol stack
and operator UI are exercised by 66 automated tests, the container is and operator UI are exercised by the automated test suite, the container is
multi-arch buildable, and the image ships at ~97 MB. What's left before multi-arch buildable, and the image ships at ~97 MB. What's left before
this looks and feels like a 1.0 product is mostly **real-hardware this looks and feels like a 1.0 product is mostly **real-hardware
validation** plus a small batch of features that can only sensibly be validation** plus a small batch of features that can only sensibly be
+8 -8
View File
@@ -288,18 +288,17 @@ warning-free. Replaced `format!()`-into-`String` with
`Reverse`, fixed `map_or(false, …)``is_some_and`, and a handful of `Reverse`, fixed `map_or(false, …)``is_some_and`, and a handful of
other idiom fixes. other idiom fixes.
**UI overhaul** for the v0.2.0 pre-beta milestone: **UI overhaul** for the pre-beta milestone:
- Light + dark themes via `:root[data-theme=light]` token swap. - Light + dark themes via `:root[data-theme=light]` token swap.
Toggled by a top-right button or the `T` key. Persisted in Toggled by a top-right button or the `T` key. Persisted in
localStorage; pre-paint inline script avoids dark→light flash. localStorage; pre-paint inline script avoids dark→light flash.
- New SVG logos: a refined anvil (`logo.svg`) and a SMIL-animated - New SVG logos: a refined OpenPXE mark (`logo.svg`) and a compact
`anvil-forge.svg` (rising sparks + pulsing underglow). Pure SVG — SMIL-animated loader (`loader.svg`). Pure SVG, embedded in the binary.
no GIFs, no CSS keyframes for the sparks. - Deployment progress widget on the Dashboard and Queue: animated
- "Forge progress" widget on the Dashboard and Queue: animated OpenPXE mark paired with a `linear-gradient(warn → accent)` progress bar
anvil paired with a `linear-gradient(warn → accent)` progress bar
with a moving sheen. Goes idle (greyscale, no sheen) at zero with a moving sheen. Goes idle (greyscale, no sheen) at zero
imaging load. imaging load.
- Loader replaced "Loading" text with the same anvil. - Loader replaced "Loading..." text with the same OpenPXE mark.
- Sidebar gains a **Hosts** tab. - Sidebar gains a **Hosts** tab.
**Windows boot validation**: **Windows boot validation**:
@@ -317,7 +316,8 @@ other idiom fixes.
fixes: explicit `net start Workstation` before `net use`, surfaces fixes: explicit `net start Workstation` before `net use`, surfaces
errors instead of blind retries. errors instead of blind retries.
**Test count**: 66 → up from 56 in v0.1.0. **Test posture**: protocol, HTTP, ISO-store, Windows script, queue, metrics,
and UI-offline checks all run in the workspace test suite.
## What's deferred to Phase 6 ## What's deferred to Phase 6
+6 -6
View File
@@ -115,7 +115,7 @@ Two options. Pick one.
Big ISOs stream — there is no 2 GB limit, but expect upload to be Big ISOs stream — there is no 2 GB limit, but expect upload to be
throttled by your browser ↔ host link. The UI shows a progress bar; the throttled by your browser ↔ host link. The UI shows a progress bar; the
animated anvil on the Dashboard tab fires up while imaging is in animated OpenPXE mark on the Dashboard tab fires up while imaging is in
flight. flight.
### 2b. Bulk seed from a directory (recommended for fresh deploys / CI) ### 2b. Bulk seed from a directory (recommended for fresh deploys / CI)
@@ -290,7 +290,7 @@ INFO openpxe::http: GET /iso/ubuntu-…/casper/initrd Range=bytes=0- 200 OK 75
``` ```
The **Terminal** tab in the web UI shows the same thing live, plus a The **Terminal** tab in the web UI shows the same thing live, plus a
short whitelisted command palette (`status`, `clients`, `gate`, short whitelisted command palette (`status`, `clients`, `queue`,
`hosts`, `log`). `hosts`, `log`).
### 5d. Internet-side ISO sources ### 5d. Internet-side ISO sources
@@ -347,19 +347,19 @@ default for production hardware.
## 7. Re-imaging — the “Queued Deployment” flow ## 7. Re-imaging — the “Queued Deployment” flow
Different scenario: you have **a rack of 30 servers** to image Different scenario: you have **a rack of 30 servers** to image
identically, all at once. Dont bind 30 MACs by hand. Use the gate. identically, all at once. Dont bind 30 MACs by hand. Use the queue.
1. **Dont** create host bindings. 1. **Dont** create host bindings.
2. PXE-boot every machine. They land on the menu. 2. PXE-boot every machine. They land on the menu.
3. On each: select **Queued Deployment**. They get position #1, #2, 3. On each: select **Queued Deployment**. They get position #1, #2,
…, #30 and start long-polling. …, #30 and start long-polling.
4. In the UI: **Forge Gate** tab shows all 30 lined up. Pick the 4. In the UI: **Queue** tab shows all 30 lined up. Pick the
ISO, click **Assign to all waiting**. ISO, click **Assign to all waiting**.
5. Every clients open long-poll wakes up at the same instant and 5. Every clients open long-poll wakes up at the same instant and
chains the same boot script. They all start imaging chains the same boot script. They all start imaging
simultaneously — the “horse race gate” opens. simultaneously.
The animated anvil widget on the Dashboard runs while any client is The animated OpenPXE progress widget on the Dashboard runs while any client is
still in the kernel-fetch phase. still in the kernel-fetch phase.
--- ---