Compare commits

..
7 Commits
Author SHA1 Message Date
Miles Ward 90a23a8c96 docs(runbook): apply OpenPXE rebrand to network-boot runbook
The Linux network-boot runbook landed on origin/main while the v0.3.0
rebrand was in flight on local main. Runs the same string-rewrite
pass: PXEForge → OpenPXE, Gated → Queued, /api/gate → /api/queue,
PXEFORGE_ env vars → OPENPXE_, container path under
/var/lib/openpxe.
2026-05-06 14:14:09 -04:00
Miles Ward e3452fe976 v0.3.0 — rebrand: PXEForge → OpenPXE, Gated → Queued Deployment
Full rename to match the openpxe.com brand. The product now reads as a
polished open-source project rather than a personal-tool nickname:
the anvil/forge metaphor is gone, replaced with the rainbow-horizon
brand mark from the marketing site.

## Naming changes

**PXEForge → OpenPXE** everywhere it's user-visible or developer-
facing:
- All 8 crate package names (`pxeforge-*` → `openpxe-*`).
- The bin crate dir + binary (`crates/pxeforge` → `crates/openpxe`,
  `bin = "openpxe"`).
- Env vars: `PXEFORGE_*` → `OPENPXE_*` (no compat shim — pre-beta).
- Tracing targets: `pxeforge::*` → `openpxe::*`.
- Prometheus metrics: `pxeforge_*` → `openpxe_*` (pre-beta; nobody
  has dashboards on these yet).
- Container image: `gitea.milesward.dev/mward4/openpxe:0.3.0`.
- All in-tree paths: `/var/lib/openpxe/{isos,work,smb}`,
  `/usr/share/openpxe/ipxe`, `/etc/openpxe/...`.
- Unraid template renamed `pxeforge.xml` → `openpxe.xml`.
- README, NEXT_PHASE.md, architecture.md, comments, and the WebUI
  brand string.

**Gated Deployment → Queued Deployment** as the user-facing concept:
- `Settings::TimeoutAction::GatedDeployment` →
  `QueuedDeployment` (with `#[serde(alias = "gated_deployment")]`
  so v0.2.0 settings.json files keep deserializing).
- Rust types: `Gate` → `QueueEntry`, `GateQueue` → `DeploymentQueue`,
  `GateInner` → `QueueEntryInner`.
- File: `crates/core/src/gate.rs` → `crates/core/src/queue.rs`.
- HTTP routes: `/api/gate/*` → `/api/queue/*`. The JSON list key
  flipped from `"gates"` to `"entries"` to match.
- iPXE shortcut: `/boot/_gate.ipxe` → `/boot/_queue.ipxe`. The
  top-level menu's item id is now `queue` instead of `gate`.
- WebUI sidebar tab: "Forge Gate" → "Queue".
- Field on `AppState`: `gates` → `queue`.

## Brand assets

The anvil + forging-sparks logos are dropped:
- `logo.svg` is now a 24×24 medallion filled with the
  `rainbow-horizon` gradient from openpxe.com (sliding hue rotation
  via SMIL on the gradient stops, no JS needed).
- `anvil-forge.svg` renamed to `loader.svg` and rebuilt as a 64×64
  louder version of the same disc — used for page-load transitions
  and the imaging-progress widget. Adds a subtle scale pulse and a
  white inner-glow so it has dimensionality on either theme.

## CSS rename

- `.forge-progress` → `.queue-progress`
- `.forge-progress .anvil` → `.queue-progress .mark`
- `@keyframes forge-sheen` → `queue-sheen`
- `.loader .anvil` → `.loader .mark`
- "Heating the forge…" loader text → "Loading…"

The rest of the layout is untouched. Light/dark theme tokens and the
sidebar/topbar structure carry over from v0.2.0 unchanged — the
brief was "keeping the UI similar."

## Validation

- `cargo build --workspace` — clean.
- `cargo clippy --workspace --all-targets` — no warnings.
- `cargo test --workspace` — **66 tests passing**, same as v0.2.0.
- Local smoke run against the rebuilt release binary verifies:
  - `/boot.ipxe` emits `Queued Deployment` + `item queue` + chains
    `/boot/_queue.ipxe`
  - `/api/queue` returns `{count, entries}`
  - `/metrics` emits `openpxe_queue_count` (renamed)
  - `/assets/logo.svg` and `/assets/loader.svg` serve the new
    rainbow brand SVGs
  - `/api/status` reports version `0.3.0`

## Migration notes for operators on v0.2.0

- Container image path changed: pull
  `gitea.milesward.dev/mward4/openpxe:0.3.0` (not `pxeforge:`).
- Bind mounts: `/var/lib/openpxe/{isos,work,smb}` (not `pxeforge`).
  Move the host path or update the template.
- Env vars: replace `PXEFORGE_*` with `OPENPXE_*`. The Unraid
  template at `deploy/unraid/openpxe.xml` is already updated.
- `settings.json` carries over transparently — the
  `gated_deployment` value is accepted as an alias.
- HTTP API: any external scripts that hit `/api/gate/*` need to
  switch to `/api/queue/*`. The JSON envelope key is `entries`
  instead of `gates`.
2026-05-06 14:13:38 -04:00
503432756 c607f2e31c docs: add Linux network-boot runbook 2026-04-30 11:35:47 -04:00
Miles Ward 5206fae877 docs: add Phase 6 recommendations punch-list
Three tiers (must-do / round-out / large lifts), plus a "what I'd
skip" section calling out things from Tinkerbell and Bootimus that
don't pull their weight at PXEForge's scale (custom DHCP server,
pluggable backend abstraction, LLM-translated UI strings).

The big-ticket Tier-1 item is the real-hardware validation matrix —
everything currently passes CI tests but nothing has been booted by
real firmware yet.
2026-04-30 02:30:05 -04:00
Miles Ward 6d3d636fad v0.2.0 — pre-beta: per-MAC bindings, /metrics, themes, animated forge
This is the bulk pre-beta cleanup pass. Bumps the workspace to 0.2.0.
Test count is 56 -> 66 (+10), clippy is fully clean across the
workspace (was several dozen warnings).

## New features

**Per-MAC host bindings** (Tinkerbell smee pattern). New
`HostBindings` registry maps a MAC -> preferred boot target, persisted
to <work_dir>/hosts.json. The DHCP reply now embeds `?mac=${mac}` in
the boot.ipxe URL; iPXE substitutes the literal MAC client-side, so
the HTTP layer can short-circuit straight to the bound target instead
of rendering the menu. Reserved menu shortcuts (`_local`, `_gate`,
`_tools_menu`) are valid targets too. New /api/hosts CRUD + a Hosts
tab in the sidebar.

**Prometheus `/metrics`** endpoint. Tiny lock-free implementation —
just AtomicU64s and a Display impl, no `prometheus` / `metrics-rs`
dep. Counters: DHCP replies (per arch label), DHCP declined, TFTP
transfers (per status), TFTP bytes, HTTP requests (per route).
Gauges: ISO count, client count, gate count, gate-imaging, NFS active
mounts, uptime, build info. Plain text exposition format,
text/plain;version=0.0.4 content-type, no auth (all metric values are
non-sensitive counts).

**Light + dark themes**. CSS tokens on `:root` and
`:root[data-theme=light]`, swap by toggle button (top-right) or `T`
hotkey. Persisted in localStorage; pre-paint inline script avoids
dark<->light flash. Light palette designed against the Netbox Labs
reference screenshot — near-white surfaces, soft grey dividers,
accent unchanged for brand consistency. Terminal pane stays dark in
both themes (it's a console, that's the right read).

**Animated SVG logo + forge widget**. New `logo.svg` is a refined
silver/grey anvil. New `anvil-forge.svg` adds rising sparks and a
pulsing underglow via SMIL — pure SVG, no GIF, no JS animation loop.
Used:
  - in the **forge progress** widget on Dashboard + Forge Gate, paired
    with a `linear-gradient(warn -> accent)` bar with a moving sheen;
    goes idle (greyscale, no sheen) at zero imaging load
  - in the page-load `<div class=loader>` that replaces the old
    "Loading..." text

## Code cleanup pass

`cargo clippy --workspace --all-targets` is now warning-free. Spot
fixes across the tree:
  - `format!()`-into-`String` -> `std::fmt::Write::write!`
  - manual reverse comparators -> `Reverse`
  - `map_or(false, ...)` -> `is_some_and`
  - redundant closures -> method references
  - `r#"..."#` raw strings without `"` -> `r"..."`
  - `std::io::Error::new(Other, ...)` -> `Error::other`
  - `as i32` on `c.id()` -> `cast_signed()`
  - merged identical match arms

## Windows workflow validation

New integration test synthesizes an ISO9660 with the SOURCES\\BOOT.WIM
sentinel, uploads it, asserts:
  1. introspection labels it `windows_pe` with has_boot_wim=true,
  2. the boot entry is `BootKind::Wimboot` with all five canonical
     files (bootmgr, bootmgr.efi, bcd, boot.sdi, boot.wim),
  3. the rendered iPXE script chains wimboot with `initrd --name`
     entries for each file, and
  4. NO trust-store strings appear in the rendered output: bcdedit,
     testsigning, certutil, httpdisk, and test-signed are all
     explicitly forbidden as a hard guarantee.

WinPE bootstrap (startnet.cmd) picks up the Bootimus v0.1.58 lessons:
explicit `net start Workstation` before `net use` to avoid the SMB
client lazy-init race, and surfaces errors instead of blind retries.

## Docs

architecture.md gains a "Phase 5" section explaining the host-bindings
+ metrics + theming + Windows-test work, plus a refreshed "deferred
to Phase 6" list (real-hardware integration, autounattend library,
distro profile manifest, WoL trigger, syslog receiver, IPv6).
README updates the status line, the "what it does" list, and adds
the new Hosts/Terminal tab names.
2026-04-30 02:28:10 -04:00
Miles Ward 083277faae Add Unraid quickstart: build-and-publish script + Docker template
Three paths from "Gitea-on-Unraid + a built repo" to "Unraid pulls
PXEForge by tag":

1. scripts/build-and-publish-unraid.sh — one-shot run on the Unraid
   host. Clones from local Gitea (http://localhost:3000), runs the
   iPXE fetch, docker build, docker login + push to Gitea's container
   registry. Token never lands in the host's ~/.docker/config.json:
   we set DOCKER_CONFIG to a tempdir and rm -rf it on exit. Token
   never lands in `ps`/bash history either: --password-stdin.

2. deploy/unraid/pxeforge.xml — Docker template for the Unraid UI.
   Forces NetworkType=host (PXE needs raw L2 broadcast — bridge mode
   doesn't work, full stop), declares the right cap-add, and surfaces
   PXEFORGE_PUBLIC_IP / PXEFORGE_LOG as configurable variables.

3. deploy/unraid/README.md — three documented paths (registry, compose
   from cloned repo, docker load from tarball) and the gotchas that
   actually bite (DHCP collision, host networking, perms on
   /mnt/user/appdata, NFS-needs-CAP_SYS_ADMIN).

The build host I'm running on can't reach Unraid right now (LAN moved
to a different subnet) and the Cloudflare WAF skip rule on
gitea.milesward.dev doesn't yet cover /v2/* or /git-{upload,receive}-pack
paths, so the publish has to happen from the Unraid host itself for now.
This commit is what makes that one-shot.
2026-04-30 00:02:29 -04:00
Miles Ward cc309da062 Initial commit: PXEForge Phases 1-4
Container-native PXE boot server in Rust, designed as a clean-room
alternative to iVentoy that never touches the client OS trust store.
This is the first commit of the project; it lands the full output of
Phases 1, 2, 3, and 4 in one shot.

## Phase 1 — protocol stack

- 8-crate workspace (core, dhcp-proxy, tftp, http-api, iso-store,
  ipxe-assets, webui, pxeforge bin).
- DHCP proxy (RFC 4578): replies with boot info only, never leases —
  sidesteps CAP_NET_RAW. Architecture-aware bootfile selection from
  option 93 (BIOS, IA32, x64-UEFI alias 0x0007/0x0009, ARM64).
- TFTP server with full OACK negotiation: blksize, tsize, windowsize.
  Without it a 1 MiB iPXE binary takes 2000 packets and unusably long.
- Two-stage iPXE chain: firmware PXE -> TFTP iPXE binary -> iPXE
  re-DHCPs with user-class iPXE -> HTTP /boot.ipxe -> kernel+initrd.
- HTTP server (axum) with byte-Range ISO streaming and an in-place
  ISO9660 lookup so kernel/initrd are served from inside the ISO
  without ever extracting it to disk.
- Linux ISOs boot via kernel+initrd extraction (memdisk/sanboot fail
  for >1-2 GiB modern distros). Distro-family detection drives the
  cmdline (Debian/Ubuntu, RHEL/Fedora, openSUSE, Arch, Alpine).

## Phase 2 — UX + Windows

- Hierarchical PXE menu (Default / Installers / Tools / Gated
  Deployment) generated from settings — no hand-written .ipxe paths
  surface in the UI. Number-key + letter hotkeys, BIOS+UEFI variants
  for some RHEL ISOs.
- Gated Deployment "horse-race" queue: clients join, operator picks
  one ISO, every gate launches simultaneously via tokio::sync::Notify.
- Bootimus-pattern Windows: WimPatcher injects a CRLF startnet.cmd
  into boot.wim so vanilla WinPE net-uses an SMB share and runs
  setup.exe. All Microsoft-signed; no test certs, no testsigning,
  no httpdisk.sys. SmbManager supervises smbd start/stop/SIGHUP.
- Netbox-style dark UI, fully offline (no CDN, no external fonts).

## Phase 3 — MVP hardening

- TFTP retransmit rewrite with explicit window tracking — UEFI SNP
  clients no longer hang on files that end mid-window. 4 new tests.
- DHCP broadcast-flag honored per RFC 2131 §4.1.
- Multi-arch container (linux/amd64 + linux/arm64). Entrypoint chowns
  bind-mounts as root then drops to uid 10001 via gosu.
- /healthz + /readyz split from /api/status — readyz fails if no
  iPXE binaries are bundled.
- pxeforge seed --from <path> CLI: same pipeline as web upload (slug,
  sha256, introspection, boot-entry).
- All timestamps RFC 3339 (browser Date couldn't parse the 9-tuple).
- Gate poll retains assignment until operator releases — clients that
  retry on transient network errors reuse the assignment instead of
  falling back to the menu.
- Custom OpenShift SCC: hostNetwork + NET_BIND_SERVICE only, no
  NET_RAW.

## Phase 4 — UI restructure + remote storage

- Web UI rebuilt around six tabs inspired by the iVentoy layout:
  Dashboard / Network / Forge Gate / Storage / Terminal / About.
  Old "Monitoring/Content/Configuration" sidebar groups are gone.
- NFS share manager (crates/iso-store/src/nfs.rs): mount NFSv3 or
  NFSv4.1 shares as ISO sources instead of uploading every file
  into the PVC. New IsoSource enum on IsoMeta lets the store resolve
  Local vs NFS lazily. Persisted to <work_dir>/nfs.json; failed
  mounts surface in the UI rather than blocking startup.
- Dockerfile gains nfs-common + iproute2; mounting NFS in-container
  also requires CAP_SYS_ADMIN. Documented in docs/architecture.md.
- LogBus + tracing layer in core: 500-line ring buffer + broadcast
  channel feed an SSE endpoint at /api/log/stream.
- Operator terminal at /api/terminal: whitelisted commands (status,
  isos, clients, gate, nfs, smb, log) — deliberately not a shell.
  Output mirrored onto the LogBus so the live tail and the terminal
  pane share one timeline.
- Network tab: read-only nic_name / subnet_mask / gateway probed
  from `ip` at startup; only DNS server is editable. Editing IP/mask
  on a hot UI would silently break PXE for every client mid-boot.
- Bootimus parity (releases v0.1.55 -> v0.1.62): amber row tint on
  un-bootable ISOs with inline reasons, dashboard "won't boot" panel.

## Tests

56 tests passing across the workspace:
- 16 core (LogBus, gate, settings, arch, client)
- 1 dhcp-proxy (raw option-93 extraction)
- 8 http-api unit (range parsing, terminal split/format)
- 13 http-api integration (gated deployment, range, settings, NFS,
  terminal, log SSE, network endpoint, ui assets, no-external-urls)
- 12 iso-store (introspect, slugify, smb, windows wim, NFS options)
- 6 tftp (RRQ parsing, plan_window edges)

cargo build --workspace and cargo clippy --workspace --all-targets
both finish clean (warnings only, no errors).
2026-04-29 02:47:00 -04:00
41 changed files with 595 additions and 2662 deletions
+16
View File
@@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"Bash(cargo check *)",
"Bash(cargo build *)",
"Bash(cargo clippy *)",
"Bash(cargo fmt *)",
"Bash(cargo tree *)",
"Bash(cargo doc *)",
"Bash(cargo test --workspace --lib)",
"Bash(cargo test --workspace)",
"Bash(cargo --version)",
"Bash(rustc --version)"
]
}
}
+1 -2
View File
@@ -12,7 +12,7 @@ members = [
]
[workspace.package]
version = "0.4.0"
version = "0.3.0"
edition = "2021"
rust-version = "1.80"
license = "MIT OR Apache-2.0"
@@ -53,7 +53,6 @@ uuid = { version = "1.10", features = ["v4", "serde"] }
time = { version = "0.3", features = ["serde", "serde-human-readable", "formatting", "macros"] }
sha2 = "0.10"
hex = "0.4"
bcrypt = "0.15"
once_cell = "1.19"
parking_lot = "0.12"
rust-embed = { version = "8.5", features = ["include-exclude"] }
+16 -17
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
clients PXE-boot them.
> **Status:** v0.3.2 / pre-beta. Phases 15 complete: full PXE stack,
> **Status:** v0.2.0 / pre-beta. Phases 15 complete: full PXE stack,
> Queued Deployment queue, NFS-share ISO sources, live tracing log + an
> operator terminal, per-MAC host bindings (Tinkerbell-style),
> Prometheus `/metrics`, light/dark theme toggle, animated OpenPXE
> imaging-progress widget, and per-ISO boot passwords. The test suite and
> clippy are part of the release checklist. Ready for real-hardware validation.
> Prometheus `/metrics`, light/dark theme toggle, animated anvil
> imaging-progress widget. **66 tests passing**, clippy clean. Ready
> for real-hardware validation.
## Design non-negotiables
@@ -41,13 +41,13 @@ clients PXE-boot them.
Tools > Utilities / OpenPXE Shell / Network Card Info
Queued Deployment
```
6. **Queued Deployment queue** — the coordinated launch flow. A client that
6. **Queued Deployment queue** — the "horse race" launch flow. A client that
selects *Queued Deployment* gets a numbered position and waits. The
operator picks an ISO in the web UI and fires it to every waiting
client simultaneously.
7. **Web UI** (Netbox-style): sidebar nav (Dashboard / Network / Queue /
Storage / Hosts / Terminal / About), light + dark themes
(toggle top-right or press `T`), animated OpenPXE progress
7. **Web UI** (Netbox-style): sidebar nav (Dashboard / Network / Forge
Gate / Storage / Hosts / Terminal / About), light + dark themes
(toggle top-right or press `T`), animated anvil "forge progress"
widget when devices are imaging. All assets served from the binary —
no external requests.
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
# 2. Build the container image (~3 min first time).
docker buildx build -f deploy/docker/Dockerfile -t openpxe:0.3.2 --load .
docker buildx build -f deploy/docker/Dockerfile -t openpxe:0.1.0 --load .
# 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.
@@ -91,7 +91,7 @@ docker run -d --name openpxe \
-e OPENPXE_DHCP_MODE=proxy \
-v $PWD/data/isos:/var/lib/openpxe/isos \
-v $PWD/data/work:/var/lib/openpxe/work \
openpxe:0.3.2
openpxe:0.1.0
# 4. Open the UI and drop an ISO in.
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.
docker buildx build --builder openpxe-multi \
--platform linux/amd64,linux/arm64 \
-t ghcr.io/YOUR-ORG/openpxe:0.3.2 \
-t ghcr.io/YOUR-ORG/openpxe:0.1.0 \
--push \
-f deploy/docker/Dockerfile .
```
@@ -155,10 +155,10 @@ docker run --rm \
-v /my/iso-library:/seed:ro \
-v openpxe-data:/var/lib/openpxe/isos \
-e OPENPXE_PUBLIC_IP=10.0.0.5 \
openpxe:0.3.2 seed --from /seed
openpxe:0.1.0 seed --from /seed
# Dry run first to see what would be imported:
docker run --rm -v /my/iso-library:/seed:ro openpxe:0.3.2 seed --from /seed --dry-run
docker run --rm -v /my/iso-library:/seed:ro openpxe:0.1.0 seed --from /seed --dry-run
```
### Environment overrides
@@ -273,7 +273,7 @@ operational constraints inherited from the design:
## Queued Deployment
The coordinated launch flow, end to end:
The "horse race" launch flow, end to end:
1. A client boots and picks **Queued Deployment** in the PXE menu (or falls
through on timeout with the default `timeout_action`).
@@ -285,9 +285,8 @@ The coordinated launch flow, end to end:
The server broadcasts the assignment to every queued client via a
`tokio::sync::Notify`; each client's next poll returns the boot script
for the chosen image.
5. Every client chains the same image at effectively the same moment. The
queue stays visible until the operator releases entries, which keeps a
useful audit trail during hardware testing.
5. Every client chains the same image at effectively the same moment — the
queue releases and the horses run together.
No user-facing iPXE anywhere in this flow. The client only ever runs
scripts we generate; the operator only interacts with the web UI.
+1 -4
View File
@@ -126,10 +126,7 @@ mod tests {
fn bootfile_names_stable() {
assert_eq!(ClientArch::LegacyX86.ipxe_bootfile(), Some("undionly.kpxe"));
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);
}
-249
View File
@@ -1,249 +0,0 @@
//! Boot-event log — "who installed what, when, from where".
//!
//! Each `/boot/<entry>.ipxe` fetch that actually goes on to serve a boot
//! script lands an entry here. The log is bounded in memory (newest-first,
//! ring-buffered at [`BootLog::CAP`]) and is mirrored append-only to
//! `<work_dir>/boot_log.jsonl`. Mirrors `HostBindings`'s "in-memory is
//! authoritative, disk is a cache" policy — a corrupt log file should
//! never block PXE for the network.
//!
//! We deliberately don't push these onto the `LogBus` (the operator
//! terminal stream). The terminal already shows the http traces; the
//! Host log is a curated, persistent, easy-to-scan view of "what got
//! imaged on what hardware" and conflating the two would be noisy.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::io::Write;
use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::Arc;
use time::OffsetDateTime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootEvent {
#[serde(with = "time::serde::rfc3339")]
pub timestamp: OffsetDateTime,
/// Lowercase, colon-separated. `None` when iPXE didn't supply
/// `?mac=${mac}` in the chain URL (older bookmarks, custom scripts).
pub mac: Option<String>,
/// Connecting peer's IP — taken from the TCP socket when available
/// (PXE clients connect direct, no reverse proxy), and falls back to
/// `X-Forwarded-For` for the rare case where one is present.
pub ip: Option<IpAddr>,
/// `BootEntry::id` — the same id used in `/boot/<id>.ipxe`.
pub target_id: String,
/// Human-friendly label: the ISO's filename / volume label / entry
/// title. Pre-resolved at log time so the UI can render without
/// joining against the ISO store (and so "what image was installed?"
/// survives the operator deleting the ISO later).
pub target_title: String,
}
/// In-memory ring + disk-backed append log of boot events. Cheap to
/// clone; the inner state is `Arc<RwLock<_>>`.
#[derive(Debug, Clone)]
pub struct BootLog {
path: Arc<PathBuf>,
inner: Arc<RwLock<VecDeque<BootEvent>>>,
}
impl BootLog {
/// Newest entries we retain in memory. Past this, the oldest gets
/// evicted — the on-disk JSONL keeps the full history for offline
/// inspection. 500 covers a typical install-day's worth without
/// turning the Hosts tab into a wall of text.
pub const CAP: usize = 500;
/// Load up to `CAP` newest events from `<work_dir>/boot_log.jsonl`,
/// or start empty if the file is missing / unreadable.
#[must_use]
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("boot_log.jsonl");
let mut events = VecDeque::with_capacity(Self::CAP);
if let Ok(text) = std::fs::read_to_string(&path) {
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<BootEvent>(line) {
Ok(ev) => {
if events.len() == Self::CAP {
events.pop_front();
}
events.push_back(ev);
}
Err(e) => {
tracing::warn!(
target: "openpxe::boot_log",
"skipping unparseable boot_log line: {e}"
);
}
}
}
}
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(events)),
}
}
/// Append an event. Persistence is best-effort and never blocks the
/// caller on a failed write (the in-memory copy is the source of
/// truth for the live UI; the JSONL is just for crash survival).
pub fn record(&self, ev: &BootEvent) {
// Push into the ring first so a slow / failing disk doesn't lose
// events for the live UI.
{
let mut g = self.inner.write();
if g.len() == Self::CAP {
g.pop_front();
}
g.push_back(ev.clone());
}
tracing::info!(
target: "openpxe::boot_log",
mac = ev.mac.as_deref().unwrap_or("?"),
ip = ev.ip.map(|i| i.to_string()).as_deref().unwrap_or("?"),
target = %ev.target_id,
"boot event"
);
// Append to disk. We tolerate write failures — they'd show up as
// missing entries on the next restart only.
let mut line = match serde_json::to_string(ev) {
Ok(s) => s,
Err(e) => {
tracing::warn!(target: "openpxe::boot_log", "serialize boot event: {e}");
return;
}
};
line.push('\n');
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(self.path.as_path())
{
Ok(mut f) => {
if let Err(e) = f.write_all(line.as_bytes()) {
tracing::warn!(target: "openpxe::boot_log", "append boot_log.jsonl: {e}");
}
}
Err(e) => {
tracing::warn!(target: "openpxe::boot_log", "open boot_log.jsonl: {e}");
}
}
}
/// Newest-first snapshot, up to `CAP` entries.
#[must_use]
pub fn list(&self) -> Vec<BootEvent> {
let g = self.inner.read();
// VecDeque preserves insertion order; reverse so newest is first.
g.iter().rev().cloned().collect()
}
#[must_use]
pub fn len(&self) -> usize {
self.inner.read().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Wipe in-memory + the on-disk file. Used by the `terminal clear`
/// equivalent or future operator action; not currently wired to a UI
/// button but exposed for completeness.
pub fn clear(&self) {
self.inner.write().clear();
let _ = std::fs::remove_file(self.path.as_path());
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn ev(target: &str, mac: Option<&str>) -> BootEvent {
BootEvent {
timestamp: OffsetDateTime::now_utc(),
mac: mac.map(str::to_string),
ip: Some("10.0.0.42".parse().unwrap()),
target_id: target.into(),
target_title: format!("{target}.iso"),
}
}
#[test]
fn record_then_list_is_newest_first() {
let dir = tempdir().unwrap();
let log = BootLog::load_or_default(dir.path());
assert!(log.is_empty());
log.record(&ev("alpha", Some("aa:bb:cc:00:00:01")));
log.record(&ev("beta", Some("aa:bb:cc:00:00:02")));
let list = log.list();
assert_eq!(list.len(), 2);
assert_eq!(list[0].target_id, "beta");
assert_eq!(list[1].target_id, "alpha");
}
#[test]
fn round_trip_through_disk() {
let dir = tempdir().unwrap();
let log = BootLog::load_or_default(dir.path());
log.record(&ev("alpha", Some("aa:bb:cc:00:00:01")));
log.record(&ev("beta", None));
drop(log);
let log2 = BootLog::load_or_default(dir.path());
assert_eq!(log2.len(), 2);
let list = log2.list();
assert_eq!(list[0].target_id, "beta");
assert_eq!(list[1].target_id, "alpha");
assert!(list[0].mac.is_none());
assert_eq!(list[1].mac.as_deref(), Some("aa:bb:cc:00:00:01"));
}
#[test]
fn ring_evicts_oldest_past_cap() {
let dir = tempdir().unwrap();
let log = BootLog::load_or_default(dir.path());
for i in 0..(BootLog::CAP + 5) {
log.record(&ev(&format!("e{i}"), None));
}
assert_eq!(log.len(), BootLog::CAP);
let list = log.list();
// Newest first; the most recent push is the last index inserted.
assert_eq!(list[0].target_id, format!("e{}", BootLog::CAP + 4));
// Oldest in-memory should be the 6th push (0..5 were evicted).
assert_eq!(list[BootLog::CAP - 1].target_id, "e5");
}
#[test]
fn clear_wipes_memory_and_disk() {
let dir = tempdir().unwrap();
let log = BootLog::load_or_default(dir.path());
log.record(&ev("alpha", None));
log.clear();
assert!(log.is_empty());
let log2 = BootLog::load_or_default(dir.path());
assert!(log2.is_empty());
}
#[test]
fn corrupt_disk_lines_are_skipped_not_fatal() {
// Write a file with one valid + one garbage line; loader should
// surface the valid one and skip the garbage.
let dir = tempdir().unwrap();
let path = dir.path().join("boot_log.jsonl");
let valid = serde_json::to_string(&ev("ok", Some("aa:bb:cc:00:00:09"))).unwrap();
std::fs::write(&path, format!("{valid}\nNOT_JSON\n{valid}\n")).unwrap();
let log = BootLog::load_or_default(dir.path());
assert_eq!(log.len(), 2);
}
}
+3 -9
View File
@@ -56,9 +56,7 @@ impl ClientRegistry {
) {
let mut guard = self.inner.write();
let now = OffsetDateTime::now_utc();
let entry = guard
.entry(mac.to_string())
.or_insert_with(|| ClientSnapshot {
let entry = guard.entry(mac.to_string()).or_insert_with(|| ClientSnapshot {
mac: mac.to_string(),
last_ip: ip,
arch,
@@ -69,12 +67,8 @@ impl ClientRegistry {
selected_target: None,
});
entry.last_seen = now;
if ip.is_some() {
entry.last_ip = ip;
}
if arch.is_some() {
entry.arch = arch;
}
if ip.is_some() { entry.last_ip = ip; }
if arch.is_some() { entry.arch = arch; }
entry.events.push((now, event));
// Cap event history per client to keep memory bounded.
const MAX_EVENTS: usize = 64;
+5 -13
View File
@@ -68,7 +68,7 @@ pub struct Paths {
pub work_dir: PathBuf,
/// Directory containing bundled iPXE binaries (undionly.kpxe, snponly.efi, ...).
pub ipxe_dir: PathBuf,
/// Path to the wimboot binary for Windows ISOs (optional — feature-controlled).
/// Path to the wimboot binary for Windows ISOs (optional — feature-gated).
pub wimboot_path: Option<PathBuf>,
/// Directory under which Windows ISOs are extracted and served via SMB.
/// Only used when `settings.windows_enabled = true`. Defaults to
@@ -128,24 +128,16 @@ impl Config {
/// Call this after loading the TOML file so env takes precedence.
pub fn apply_env(&mut self) {
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(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(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(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") {
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.
pub mac: String,
/// Preferred boot entry id (matches a `BootEntry::id` in the iso
/// store) OR one of the reserved menu names: `_local`, `_queue`,
/// store) OR one of the reserved menu names: `_local`, `_gate`,
/// `_tools_menu`. Empty string falls back to the menu.
pub target: String,
/// Optional human-readable label shown in the UI (`"Tom's laptop"`,
+2 -4
View File
@@ -3,23 +3,21 @@
#![forbid(unsafe_code)]
pub mod arch;
pub mod boot_log;
pub mod client;
pub mod config;
pub mod error;
pub mod queue;
pub mod host_bindings;
pub mod log_bus;
pub mod metrics;
pub mod queue;
pub mod settings;
pub use arch::{ClientArch, FirmwareClass};
pub use boot_log::{BootEvent, BootLog};
pub use client::{ClientEvent, ClientRegistry, ClientSnapshot};
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
pub use error::{Error, Result};
pub use queue::{Gate, DeploymentQueue};
pub use host_bindings::{normalize_mac, HostBinding, HostBindings};
pub use log_bus::{LogBus, LogBusLayer, LogLine};
pub use metrics::{HttpRoute, Metrics};
pub use queue::{DeploymentQueue, QueueEntry};
pub use settings::{Settings, SettingsStore, TimeoutAction};
+11 -62
View File
@@ -87,9 +87,7 @@ impl Metrics {
}
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 ───────────────────────────────────────────────────────────
@@ -183,10 +181,7 @@ 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,
@@ -206,10 +201,7 @@ 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");
for (label, counter) in [
("boot_script", &i.http_boot_script),
@@ -226,53 +218,14 @@ impl Metrics {
}
// Gauges.
write_gauge(
&mut out,
"openpxe_iso_count",
"ISOs currently registered (local + NFS).",
i.iso_count.load(Ordering::Relaxed),
"",
);
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,
"",
);
write_gauge(&mut out, "openpxe_iso_count", "ISOs currently registered (local + NFS).", i.iso_count.load(Ordering::Relaxed), "");
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, "openpxe_build_info{{version=\"{version}\"}} 1");
@@ -306,11 +259,7 @@ mod tests {
m.record_http(HttpRoute::Api);
m.set_iso_count(3);
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!(out.contains("openpxe_dhcp_replies_total{arch=\"uefi\"} 1"));
assert!(out.contains("openpxe_dhcp_replies_total{arch=\"bios\"} 1"));
+24 -28
View File
@@ -1,16 +1,16 @@
//! Queued Deployment queue.
//!
//! When a client selects "Queued Deployment" at the PXE menu, iPXE POSTs to
//! `/api/queue/join` and receives a queue position. It then enters a poll
//! `/api/queue/join` and receives a gate position. It then enters a poll
//! 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
//! 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.
//!
//! The WebUI shows the queue (`GET /api/queue`) and issues
//! The WebUI shows the queue (`GET /api/gate`) and issues
//! `POST /api/queue/assign { iso_id, entry_ids: [...] }` to launch a single
//! ISO across many queued clients at once. Every waiting machine receives
//! the assignment without operator visits at the rack.
//! ISO across many gated clients at once. This is the "horse-race gate"
//! UX the user asked for — every horse leaves the line simultaneously.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
@@ -23,11 +23,11 @@ use uuid::Uuid;
use crate::ClientArch;
/// Per-client queue state visible to the WebUI.
/// Per-gate state visible to the WebUI.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueueEntry {
pub struct Gate {
pub id: String,
/// 1-based queue position — position 1 is whoever got there first.
/// 1-based race-gate position — position 1 is whoever got there first.
pub position: u32,
pub mac: String,
pub ip: Option<IpAddr>,
@@ -55,8 +55,8 @@ struct QueueEntryInner {
}
impl QueueEntryInner {
fn snapshot(&self) -> QueueEntry {
QueueEntry {
fn snapshot(&self) -> Gate {
Gate {
id: self.id.clone(),
position: self.position,
mac: self.mac.clone(),
@@ -80,25 +80,21 @@ impl DeploymentQueue {
Arc::new(Self::default())
}
/// Add a client to the queue. Returns the current queue snapshot. If the
/// MAC is already queued, the existing entry is returned unchanged —
/// Add a client to the gate. Returns the new `Gate` snapshot. If the
/// MAC is already queued, the existing gate is returned unchanged —
/// retrying iPXE clients don't duplicate their slot.
pub fn join(&self, mac: &str, ip: Option<IpAddr>, arch: Option<ClientArch>) -> QueueEntry {
pub fn join(&self, mac: &str, ip: Option<IpAddr>, arch: Option<ClientArch>) -> Gate {
let now = OffsetDateTime::now_utc();
let mut guard = self.inner.write();
if let Some(existing) = guard.values_mut().find(|g| g.mac == mac) {
existing.last_poll_at = now;
if ip.is_some() {
existing.ip = ip;
}
if arch.is_some() {
existing.arch = arch;
}
if ip.is_some() { existing.ip = ip; }
if arch.is_some() { existing.arch = arch; }
return existing.snapshot();
}
// Queue position = max(position) + 1, or 1 if empty.
// Race position = max(position) + 1, or 1 if empty.
let next_pos = guard.values().map(|g| g.position).max().unwrap_or(0) + 1;
let id = Uuid::new_v4().to_string();
let inner = QueueEntryInner {
@@ -117,24 +113,24 @@ impl DeploymentQueue {
snap
}
/// Look up the `Notify` primitive for a given queue entry id, for long-polling.
/// Look up the `Notify` primitive for a given gate id, for long-polling.
#[must_use]
pub fn notifier(&self, entry_id: &str) -> Option<Arc<Notify>> {
self.inner.read().get(entry_id).map(|g| g.notify.clone())
}
/// Update the last-poll timestamp (keeps the queue's "live" indicator
/// Update the last-poll timestamp (keeps the gate's "live" indicator
/// fresh in the UI) and return the current snapshot. Returns None if
/// the entry was released/expired between requests.
pub fn touch(&self, entry_id: &str) -> Option<QueueEntry> {
/// the gate was released/expired between requests.
pub fn touch(&self, entry_id: &str) -> Option<Gate> {
let mut guard = self.inner.write();
let g = guard.get_mut(entry_id)?;
g.last_poll_at = OffsetDateTime::now_utc();
Some(g.snapshot())
}
/// Operator assigns an ISO entry (boot_entry id) to one or more clients.
/// Returns the number of queue entries that were updated. Entries not in the
/// Operator assigns an ISO entry (boot_entry id) to one or more gates.
/// Returns the number of gates that were updated. Gates not in the
/// queue are silently skipped.
pub fn assign(&self, entry_ids: &[String], target: &str) -> usize {
let mut guard = self.inner.write();
@@ -149,9 +145,9 @@ impl DeploymentQueue {
updated
}
/// Remove a queue entry and return its final snapshot. Called after the client
/// Remove a gate and return its final snapshot. Called after the client
/// has successfully chained onto its assignment.
pub fn release(&self, entry_id: &str) -> Option<QueueEntry> {
pub fn release(&self, entry_id: &str) -> Option<Gate> {
let mut guard = self.inner.write();
let g = guard.remove(entry_id)?;
g.notify.notify_waiters();
@@ -166,7 +162,7 @@ impl DeploymentQueue {
}
#[must_use]
pub fn list(&self) -> Vec<QueueEntry> {
pub fn list(&self) -> Vec<Gate> {
let guard = self.inner.read();
let mut v: Vec<_> = guard.values().map(QueueEntryInner::snapshot).collect();
v.sort_by_key(|g| g.position);
+8 -14
View File
@@ -48,9 +48,9 @@ pub struct Settings {
pub default_local_hdd: bool,
/// When a client hits the Queued Deployment item, how long (seconds) to
/// hold it in queue before giving up and falling back to the menu.
/// hold it at the gate before giving up and falling back to the menu.
/// 0 = forever.
pub queue_wait_max_secs: u32,
pub gate_wait_max_secs: u32,
/// Optional DNS server advertised on the Network tab. Purely
/// informational today — OpenPXE does not run a DNS server, but
@@ -67,8 +67,11 @@ pub enum TimeoutAction {
/// Chain the "Boot from Local HDD" entry.
LocalHdd,
/// Put the client into the deployment queue, waiting for operator
/// assignment.
/// assignment. The serde alias keeps v0.2.0 settings.json files
/// readable after the v0.3.0 rename — old `"gated_deployment"`
/// values deserialize transparently.
#[default]
#[serde(alias = "gated_deployment")]
QueuedDeployment,
}
@@ -81,7 +84,7 @@ impl Default for Settings {
smb_host_override: String::new(),
extra_kernel_args: String::new(),
default_local_hdd: true,
queue_wait_max_secs: 0,
gate_wait_max_secs: 0,
dns_server: String::new(),
}
}
@@ -112,10 +115,7 @@ impl SettingsStore {
},
Err(_) => Settings::default(),
};
Arc::new(Self {
path,
inner: RwLock::new(initial),
})
Arc::new(Self { path, inner: RwLock::new(initial) })
}
#[must_use]
@@ -181,12 +181,6 @@ mod tests {
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]
fn corrupt_file_falls_back_to_default() {
let dir = tempdir().unwrap();
+4 -14
View File
@@ -56,17 +56,11 @@ pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
// it'll then do the same script-fetch the iPXE path does.
let name = ctx.arch.ipxe_bootfile().unwrap_or("snponly.efi");
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() {
Some(name) => BootDirective::TftpIpxe {
filename: name.to_string(),
},
Some(name) => BootDirective::TftpIpxe { filename: name.to_string() },
None => BootDirective::Ignore,
},
FirmwareClass::Other => BootDirective::Ignore,
@@ -113,16 +107,12 @@ pub fn build_reply(ctx: &ReplyContext<'_>, directive: &BootDirective) -> Option<
match directive {
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()));
}
BootDirective::HttpScript { url } => {
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,
}
+11 -32
View File
@@ -4,7 +4,9 @@
use crate::reply::{build_reply, decide, BootDirective, ReplyContext};
use dhcproto::v4::{DhcpOption, Message, OptionCode};
use dhcproto::{Decodable, Decoder, Encodable, Encoder};
use openpxe_core::{ClientArch, ClientEvent, ClientRegistry, FirmwareClass};
use openpxe_core::{
ClientArch, ClientEvent, ClientRegistry, FirmwareClass,
};
use socket2::{Domain, Protocol, Socket, Type};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use std::sync::Arc;
@@ -84,22 +86,11 @@ impl DhcpProxyServer {
) -> anyhow::Result<()> {
let request = Message::decode(&mut Decoder::new(data))?;
let vendor_class = request
.opts()
.get(OptionCode::ClassIdentifier)
.and_then(|o| {
if let DhcpOption::ClassIdentifier(v) = o {
Some(v.as_slice())
} else {
None
}
let vendor_class = request.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| {
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);
if matches!(class, FirmwareClass::Other) {
@@ -144,9 +135,7 @@ impl DhcpProxyServer {
}
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);
reply.encode(&mut Encoder::new(&mut out))?;
@@ -214,10 +203,7 @@ fn bind_udp(bind: IpAddr, port: u16, broadcast: bool) -> anyhow::Result<UdpSocke
fn format_mac(chaddr: &[u8]) -> String {
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)
@@ -231,17 +217,10 @@ fn extract_raw_arch(packet: &[u8]) -> Option<u16> {
let mut i = 0;
while i < opts.len() {
let code = opts[i];
if code == 0xff {
return None;
} // END
if code == 0x00 {
if code == 0xff { return None; } // END
if code == 0x00 { i += 1; continue; } // PAD
i += 1;
continue;
} // PAD
i += 1;
if i >= opts.len() {
return None;
}
if i >= opts.len() { return None; }
let len = opts[i] as usize;
i += 1;
if code == 93 && len >= 2 && i + 2 <= opts.len() {
+91 -516
View File
@@ -14,8 +14,8 @@
//! | `/api/*` | JSON/HTML API for the web UI |
use crate::ipxe_script::{
render_entry, render_family_menu, render_local_hdd, render_menu, render_nic_info,
render_queue_entry, render_shell, render_tools_menu, render_util,
render_entry, render_family_menu, render_queue_entry, render_local_hdd,
render_menu, render_nic_info, render_shell, render_tools_menu, render_util,
};
use crate::iso_fs;
use crate::log_stream;
@@ -23,14 +23,13 @@ use crate::state::AppState;
use crate::terminal;
use axum::{
body::Body,
extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path as AxumPath, Query, State},
extract::{DefaultBodyLimit, Multipart, Path as AxumPath, Query, State},
http::{header, HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
routing::{delete, get, post},
Json, Router,
};
use openpxe_core::{BootEvent, ClientEvent, Settings};
use std::net::SocketAddr;
use openpxe_core::{ClientEvent, Settings};
use openpxe_ipxe_assets::asset_bytes;
use openpxe_iso_store::{IsoMeta, NfsAddRequest};
use serde::Deserialize;
@@ -62,12 +61,6 @@ pub fn build_router(state: AppState) -> Router {
// JSON API.
.route("/api/isos", get(api_list_isos).post(api_upload_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/status", get(api_status))
.route("/api/settings", get(api_get_settings).put(api_put_settings))
@@ -92,9 +85,6 @@ pub fn build_router(state: AppState) -> Router {
// pins a MAC to a boot entry; /boot.ipxe?mac=... chains directly.
.route("/api/hosts", get(api_hosts_list).post(api_hosts_upsert))
.route("/api/hosts/:mac", delete(api_hosts_remove))
// v0.4.0: rolling "host log" of boot events — what image actually
// started installing on what MAC/IP, and when. Persisted to disk.
.route("/api/boot-log", get(api_boot_log))
// Phase 5: Prometheus scrape endpoint. Plain text exposition
// format. No auth — the metrics surface is intentionally
// boring (counts, no payloads).
@@ -109,67 +99,42 @@ pub fn build_router(state: AppState) -> Router {
async fn index(State(state): State<AppState>) -> Response {
let html = openpxe_webui::index_html(&state.public_base_url);
(
[(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
)],
html,
)
([(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"))], html)
.into_response()
}
async fn ui_js() -> Response {
(
[(
header::CONTENT_TYPE,
HeaderValue::from_static("application/javascript"),
)],
[(header::CONTENT_TYPE, HeaderValue::from_static("application/javascript"))],
openpxe_webui::app_js(),
)
.into_response()
).into_response()
}
async fn ui_css() -> Response {
(
[(header::CONTENT_TYPE, HeaderValue::from_static("text/css"))],
openpxe_webui::app_css(),
)
.into_response()
).into_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(),
)
.into_response()
).into_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(),
)
.into_response()
).into_response()
}
// ─── iPXE scripts ──────────────────────────────────────────────────────────
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()
}
@@ -179,17 +144,9 @@ fn text_plain(body: String) -> Response {
/// to the bound target instead of rendering the menu.
async fn boot_top_menu(
State(state): State<AppState>,
peer: Option<ConnectInfo<SocketAddr>>,
Query(p): Query<BootMenuParams>,
) -> Response {
// `ConnectInfo` is only populated when axum was started with
// `into_make_service_with_connect_info` (production path). Tests
// call the router via `oneshot`, which skips that wiring — we
// tolerate it by treating the peer as unknown rather than 500ing.
let peer_ip = peer.map(|c| c.0.ip());
state
.metrics
.record_http(openpxe_core::HttpRoute::BootScript);
state.metrics.record_http(openpxe_core::HttpRoute::BootScript);
let isos = state.iso_store.list();
let settings = state.settings.snapshot();
let base = &state.public_base_url;
@@ -205,33 +162,14 @@ async fn boot_top_menu(
mac = %binding.mac, target = %binding.target,
"host binding applied"
);
// Pre-record the host-binding event. Reserved menu shortcuts
// (`_local`, `_queue`, …) are operator-driven non-imaging
// targets — recording them would clutter the Host log with
// routine console activity, so we skip those and only record
// for real boot-entry ids.
if !binding.target.starts_with('_') {
let title = lookup_entry_title(&isos, &binding.target);
state.boot_log.record(&BootEvent {
timestamp: time::OffsetDateTime::now_utc(),
mac: Some(binding.mac.clone()),
ip: peer_ip,
target_id: binding.target.clone(),
target_title: title,
});
}
let target = binding.target;
let bound_mac = binding.mac;
// Reserved menu shortcuts are emitted as `_xxx`; per-entry
// boot scripts are at `/boot/<id>.ipxe`. Both share the same
// `/boot/<name>` route, so the URL is identical. We forward
// `?mac=` so the per-entry handler can record the boot into
// the Host log without depending on iPXE substitution at
// this stage.
// `/boot/<name>` route, so the URL is identical.
return text_plain(format!(
"#!ipxe\n\
echo OpenPXE: per-MAC binding -> {target}\n\
chain {base}/boot/{target}.ipxe?mac={bound_mac} || chain {base}/boot.ipxe\n"
chain {base}/boot/{target}.ipxe || chain {base}/boot.ipxe\n"
));
}
}
@@ -239,22 +177,6 @@ async fn boot_top_menu(
text_plain(render_menu(&isos, &settings, base))
}
/// Best-effort human title for a boot entry id — falls back to the id
/// itself if the ISO has been deleted between record-time and now.
fn lookup_entry_title(isos: &[openpxe_iso_store::IsoMeta], target_id: &str) -> String {
for iso in isos {
for e in &iso.boot_entries {
if e.id == target_id {
// ISO filename plus the entry title gives the operator
// both "which image" and "which variant" (e.g. wimboot
// vs sanboot) at a glance.
return format!("{}{}", iso.filename, e.title);
}
}
}
target_id.to_string()
}
#[derive(Debug, Deserialize)]
struct BootMenuParams {
/// Client MAC, supplied by iPXE via `${mac}` variable in
@@ -263,26 +185,10 @@ struct BootMenuParams {
mac: Option<String>,
}
#[derive(Debug, Deserialize)]
struct BootSubParams {
/// iPXE-supplied password token. Sent by the prompt script as
/// `?token=${password:uristring}` so special chars survive URL
/// encoding. Absent on the first request — that's how we know the
/// client hasn't been prompted yet.
token: Option<String>,
/// Client MAC, supplied by iPXE via `${mac}` in the chain URLs we
/// render. Optional — older bookmarks may omit it; the boot log
/// just records `None` in that case rather than refusing to boot.
mac: Option<String>,
}
async fn boot_sub(
State(state): State<AppState>,
peer: Option<ConnectInfo<SocketAddr>>,
AxumPath(filename): AxumPath<String>,
Query(p): Query<BootSubParams>,
) -> Response {
let peer_ip = peer.map(|c| c.0.ip());
// `/boot/<name>.ipxe` where `<name>` is either one of our reserved
// submenu names (prefixed `_`) or a boot entry id.
let name = filename.strip_suffix(".ipxe").unwrap_or(&filename);
@@ -302,74 +208,6 @@ async fn boot_sub(
for iso in &isos {
for entry in &iso.boot_entries {
if entry.id == other {
// Password prompt. If the ISO has a password set
// we block the actual boot script behind it:
// - no token -> render a prompt
// - wrong token -> render auth-fail
// - correct token -> serve the boot script
// ISO without a password ignores the token
// entirely, so per-MAC bookmarks stay simple.
if iso.is_password_protected() {
match p.token.as_deref() {
None | Some("") => {
return text_plain(crate::ipxe_script::render_password_prompt(
&entry.id,
&iso.filename,
base,
));
}
Some(token) => {
match state.iso_store.verify_password(&iso.id, token) {
Ok(true) => { /* fall through to render the entry */ }
Ok(false) => {
// Don't log the candidate — just the
// mac (when iPXE supplies one) and
// the entry id, so an operator can
// see brute-force attempts in the
// live log.
tracing::warn!(
target: "openpxe::http::boot",
entry = %other,
"wrong password supplied for protected boot entry"
);
return text_plain(
crate::ipxe_script::render_password_failed(
&entry.id, base,
),
);
}
Err(e) => {
tracing::error!(
target: "openpxe::http::boot",
entry = %other, error = %e,
"password verify failed unexpectedly"
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
"password check failed",
)
.into_response();
}
}
}
}
}
// Record the boot event. This is the canonical
// moment: password gate (if any) passed, and the
// script is about to be served — i.e. the client
// is genuinely about to start imaging.
let mac_normalized = p
.mac
.as_deref()
.map(openpxe_core::normalize_mac)
.filter(|m| !m.is_empty());
state.boot_log.record(&BootEvent {
timestamp: time::OffsetDateTime::now_utc(),
mac: mac_normalized,
ip: peer_ip,
target_id: entry.id.clone(),
target_title: format!("{} — {}", iso.filename, entry.title),
});
return text_plain(render_entry(entry, &settings, base));
}
}
@@ -391,15 +229,11 @@ 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())),
],
bytes,
)
.into_response()
).into_response()
}
// ─── ISO streaming (raw + in-ISO) ─────────────────────────────────────────
@@ -429,9 +263,7 @@ async fn iso_file(
let p = iso_path.clone();
let in_path = format!("/{path}");
let loc = tokio::task::spawn_blocking(move || iso_fs::lookup(&p, &in_path))
.await
.ok()
.flatten();
.await.ok().flatten();
let Some(loc) = loc else {
return (StatusCode::NOT_FOUND, "not found inside iso").into_response();
};
@@ -447,43 +279,21 @@ async fn stream_file_range(
) -> anyhow::Result<Response> {
let meta = tokio::fs::metadata(path).await?;
let total = meta.len();
if total == 0 {
return Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_LENGTH, 0)
.body(Body::empty())
.unwrap());
}
let Some((start, end, partial)) = parse_range(range, total) else {
return Ok(Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{total}"))
.body(Body::empty())
.unwrap());
};
let (start, end, partial) = parse_range(range, total);
let len = end - start + 1;
let mut file = tokio::fs::File::open(path).await?;
file.seek(std::io::SeekFrom::Start(start)).await?;
let reader = file.take(len);
let stream = tokio_util::io::ReaderStream::new(reader);
let body = Body::from_stream(stream);
let status = if partial {
StatusCode::PARTIAL_CONTENT
} else {
StatusCode::OK
};
let status = if partial { StatusCode::PARTIAL_CONTENT } else { StatusCode::OK };
let mut builder = Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_LENGTH, len);
if partial {
builder = builder.header(
header::CONTENT_RANGE,
format!("bytes {start}-{end}/{total}"),
);
builder = builder.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{total}"));
}
Ok(builder.body(body).unwrap())
}
@@ -506,40 +316,21 @@ async fn stream_byte_range(
.unwrap())
}
fn parse_range(h: Option<&HeaderValue>, total: u64) -> Option<(u64, u64, bool)> {
let Some(h) = h else {
return Some((0, total.saturating_sub(1), false));
};
let Ok(s) = h.to_str() else {
return Some((0, total.saturating_sub(1), false));
};
let Some(spec) = s.strip_prefix("bytes=") else {
return Some((0, total.saturating_sub(1), false));
};
fn parse_range(h: Option<&HeaderValue>, total: u64) -> (u64, u64, bool) {
let Some(h) = h else { return (0, total.saturating_sub(1), false); };
let Ok(s) = h.to_str() else { return (0, total.saturating_sub(1), false); };
let Some(spec) = s.strip_prefix("bytes=") else { return (0, total.saturating_sub(1), false); };
let spec = spec.split(',').next().unwrap_or("").trim();
if let Some(suffix) = spec.strip_prefix('-') {
if let Ok(n) = suffix.parse::<u64>() {
let n = n.min(total);
return Some((total.saturating_sub(n), total.saturating_sub(1), true));
return (total.saturating_sub(n), total.saturating_sub(1), true);
}
}
let mut parts = spec.splitn(2, '-');
let start = parts
.next()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let end = parts
.next()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(total.saturating_sub(1));
if start >= total {
return None;
}
let end = end.min(total.saturating_sub(1));
if start > end {
return None;
}
Some((start, end, true))
let start = parts.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
let end = parts.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(total.saturating_sub(1));
(start, end.min(total.saturating_sub(1)), true)
}
// ─── ISO upload / list / delete ───────────────────────────────────────────
@@ -558,204 +349,33 @@ async fn api_delete_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(
async fn api_upload_iso(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
Json(body): Json<SetPasswordBody>,
mut multipart: Multipart,
) -> 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 {
// Walk multipart parts until we find the file. Each branch logs so an
// operator chasing a "stuck" upload in the Terminal tab can see
// exactly which stage failed (no field, wrong field name, parser
// error, mid-stream drop, sha mismatch on finish, etc.).
loop {
let field_res = multipart.next_field().await;
match field_res {
Ok(Some(mut field)) => {
if field.name() != Some("file") {
tracing::debug!(
target: "openpxe::http::upload",
field = field.name().unwrap_or("?"),
"skipping non-file multipart part"
);
continue;
}
while let Ok(Some(mut field)) = multipart.next_field().await {
if field.name() != Some("file") { continue; }
let filename = field.file_name().unwrap_or("uploaded.iso").to_string();
if !filename.to_ascii_lowercase().ends_with(".iso") {
tracing::warn!(
target: "openpxe::http::upload",
filename = %filename, "rejecting non-.iso upload"
);
return (StatusCode::BAD_REQUEST, "only .iso uploads accepted")
.into_response();
return (StatusCode::BAD_REQUEST, "only .iso uploads accepted").into_response();
}
tracing::info!(
target: "openpxe::http::upload",
filename = %filename, "upload started"
);
let mut handle = match state.iso_store.begin_upload(&filename).await {
Ok(h) => h,
Err(e) => {
tracing::warn!(
target: "openpxe::http::upload",
filename = %filename, error = %e,
"begin_upload rejected (likely duplicate name)"
);
return (StatusCode::CONFLICT, format!("{e}")).into_response();
}
Err(e) => return (StatusCode::CONFLICT, format!("{e}")).into_response(),
};
// Streamed reader loop. We use an explicit `match` instead
// of `while let Ok(Some(_))` so a mid-stream `Err(_)` (a
// truncated body from a reverse proxy 524 / network drop)
// is treated as a failure rather than silently completing
// with a partial file.
let mut bytes: u64 = 0;
let mut next_log_at: u64 = 64 * 1024 * 1024;
loop {
match field.chunk().await {
Ok(Some(chunk)) => {
while let Ok(Some(chunk)) = field.chunk().await {
if let Err(e) = handle.write_chunk(&chunk).await {
tracing::error!(
target: "openpxe::http::upload",
filename = %filename, bytes,
error = %e, "write_chunk failed; aborting"
);
let _ = handle.abort().await;
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}"))
.into_response();
}
bytes += chunk.len() as u64;
if bytes >= next_log_at {
tracing::info!(
target: "openpxe::http::upload",
filename = %filename,
received_bytes = bytes,
"upload streaming"
);
// Backoff log cadence: 64 MB, 128, 256, …
next_log_at = next_log_at.saturating_mul(2);
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response();
}
}
Ok(None) => break,
Err(e) => {
tracing::error!(
target: "openpxe::http::upload",
filename = %filename, received_bytes = bytes,
error = %e,
"multipart stream ended with error (likely client \
disconnect or reverse-proxy buffer cap); aborting"
);
let _ = handle.abort().await;
return (
StatusCode::BAD_REQUEST,
format!(
"upload truncated after {bytes} bytes: {e}. \
If you went through a reverse proxy, try the \
LAN IP directly — large body buffering caps \
(Cloudflare free tier is 100 MB) commonly \
cause this."
),
)
.into_response();
}
}
}
tracing::info!(
target: "openpxe::http::upload",
filename = %filename, received_bytes = bytes,
"upload body complete; introspecting"
);
let meta = match handle.finish(&state.iso_store).await {
Ok(m) => m,
Err(e) => {
tracing::error!(
target: "openpxe::http::upload",
filename = %filename, error = %e,
"finish failed (rename/introspect)"
);
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}"))
.into_response();
}
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
};
tracing::info!(
target: "openpxe::http::upload",
iso = %meta.id, size = meta.size_bytes,
family = ?meta.introspection.family,
entries = meta.boot_entries.len(),
"upload finished"
);
return (StatusCode::CREATED, Json(meta)).into_response();
}
Ok(None) => {
tracing::warn!(target: "openpxe::http::upload", "upload had no 'file' part");
return (StatusCode::BAD_REQUEST, "no 'file' part").into_response();
}
Err(e) => {
tracing::error!(
target: "openpxe::http::upload",
error = %e,
"multipart parser error before reading any field"
);
return (
StatusCode::BAD_REQUEST,
format!("multipart parse error: {e}"),
)
.into_response();
}
}
}
(StatusCode::BAD_REQUEST, "no 'file' part").into_response()
}
// ─── health / readiness ───────────────────────────────────────────────────
@@ -763,11 +383,7 @@ async fn api_upload_iso(State(state): State<AppState>, mut multipart: Multipart)
async fn healthz() -> Response {
// Simple liveness — HTTP task is responsive. Does not touch storage or
// other subsystems so we never fail for downstream reasons.
(
[(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))],
"ok\n",
)
.into_response()
([(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))], "ok\n").into_response()
}
async fn readyz(State(state): State<AppState>) -> Response {
@@ -785,11 +401,7 @@ async fn readyz(State(state): State<AppState>) -> Response {
problems.push("iso directory not readable");
}
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 {
let body = format!("not ready:\n- {}\n", problems.join("\n- "));
(StatusCode::SERVICE_UNAVAILABLE, body).into_response()
@@ -808,22 +420,17 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
let nfs_active = nfs.iter().filter(|m| m.mounted).count();
let isos = state.iso_store.list();
let clients = state.clients.list();
let queue_entries = state.queue.list();
// Phase 4: dashboard tracks "imaging" as queue entries with an assignment
let gates = state.queue.list();
// Phase 4: dashboard tracks "imaging" as gates with an assignment
// already issued — they're the ones actively chaining a boot script.
let imaging = queue_entries
.iter()
.filter(|entry| entry.assigned_target.is_some())
.count();
let waiting = queue_entries.len() - imaging;
let imaging = gates.iter().filter(|g| g.assigned_target.is_some()).count();
let waiting = gates.len() - imaging;
// Side-effect: push gauge values out to the Prometheus surface.
// Doing it here (in the most-frequently-polled endpoint) keeps the
// gauges fresh without a dedicated scrape-time hook.
state.metrics.set_iso_count(isos.len() as u64);
state.metrics.set_client_count(clients.len() as u64);
state
.metrics
.set_queue_counts(queue_entries.len() as u64, imaging as u64);
state.metrics.set_queue_counts(gates.len() as u64, imaging as u64);
state.metrics.set_nfs_active(nfs_active as u64);
state.metrics.record_http(openpxe_core::HttpRoute::Api);
let now = time::OffsetDateTime::now_utc();
@@ -833,7 +440,7 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
"public_base_url": state.public_base_url,
"iso_count": isos.len(),
"client_count": clients.len(),
"queue_count": queue_entries.len(),
"queue_count": gates.len(),
"imaging_count": imaging,
"waiting_count": waiting,
"ipxe_assets": openpxe_ipxe_assets::list_assets(),
@@ -868,8 +475,7 @@ async fn api_put_settings(
"cannot enable Windows: 'wimboot' binary is not bundled. \
Place a signed wimboot build at assets/ipxe/wimboot and rebuild \
the container. See docs/architecture.md for details.",
)
.into_response();
).into_response();
}
}
new.smb_host_override = new.smb_host_override.trim().to_string();
@@ -882,15 +488,9 @@ async fn api_put_settings(
if let Some(smb) = &state.smb {
match (was_enabled, want_enabled) {
(false, true) => {
let _ = smb.start();
}
(true, false) => {
smb.stop();
}
(true, true) => {
let _ = smb.reconcile();
}
(false, true) => { let _ = smb.start(); }
(true, false) => { smb.stop(); }
(true, true) => { let _ = smb.reconcile(); }
(false, false) => {}
}
}
@@ -907,7 +507,7 @@ async fn api_list_queue(State(state): State<AppState>) -> Json<serde_json::Value
}
#[derive(Debug, Deserialize)]
struct QueueJoinParams {
struct GateJoinParams {
/// Client MAC from iPXE's `${mac}` variable. iPXE substitutes before
/// the HTTP request so we receive a plain colon-separated MAC.
mac: Option<String>,
@@ -918,7 +518,7 @@ struct QueueJoinParams {
/// until poll returns an actual boot script.
async fn api_queue_join(
State(state): State<AppState>,
Query(p): Query<QueueJoinParams>,
Query(p): Query<GateJoinParams>,
headers: HeaderMap,
) -> Response {
let mac = p.mac.unwrap_or_else(|| "unknown".to_string());
@@ -928,14 +528,10 @@ async fn api_queue_join(
.and_then(|s| s.split(',').next())
.and_then(|s| s.trim().parse().ok());
let queue_entry = state.queue.join(&mac, ip, None);
let gate = state.queue.join(&mac, ip, None);
state.clients.record(
&mac,
ip,
None,
ClientEvent::HttpScriptFetch {
target: "queue-join".into(),
},
&mac, ip, None,
ClientEvent::HttpScriptFetch { target: "queue-join".into() },
);
let base = &state.public_base_url;
@@ -944,12 +540,12 @@ async fn api_queue_join(
"#!ipxe\n\
echo\n\
echo ==========================================\n\
echo Queued Deployment - Queue Position {}\n\
echo Queued Deployment - Gate Position {}\n\
echo Waiting for operator to assign an image\n\
echo (Ctrl-B returns to the iPXE shell)\n\
echo ==========================================\n\
chain {base}/api/queue/poll/{}\n",
queue_entry.position, queue_entry.id
gate.position, gate.id
);
text_plain(script)
}
@@ -962,7 +558,7 @@ async fn api_queue_poll(
AxumPath(entry_id): AxumPath<String>,
) -> Response {
let Some(notify) = state.queue.notifier(&entry_id) else {
// Queue entry was released; send client back to the main menu.
// Gate was released; send client back to the main menu.
let base = &state.public_base_url;
return text_plain(format!("#!ipxe\nchain {base}/boot.ipxe\n"));
};
@@ -975,7 +571,7 @@ async fn api_queue_poll(
match snap {
// Bind `target` directly so we can't observe an Option::None between
// the guard and the unwrap (the old code had a race with concurrent
// `release`). We also do NOT release the queue entry here — the web UI
// `release`). We also do NOT release the gate here — the web UI
// operator releases it explicitly, which keeps a record of "this
// machine was assigned image X" visible until the client is known
// to have started. Clients that retry on transient network errors
@@ -986,20 +582,20 @@ async fn api_queue_poll(
tracing::info!(
target: "openpxe::queue",
entry_id=%entry_id, mac=%g.mac, target=%target,
"queue assignment delivered"
"gate assignment delivered"
);
text_plain(format!(
"#!ipxe\n\
echo Queue assignment received: {target}\n\
echo Gate assignment received: {target}\n\
chain {base}/boot/{target}.ipxe || chain {base}/api/queue/poll/{entry_id}\n"
))
}
Some(g) => {
// No assignment yet - loop and re-poll. Repaint position so the
// UI count stays accurate if other queue entries were released meanwhile.
// UI count stays accurate if other gates were released meanwhile.
text_plain(format!(
"#!ipxe\n\
echo Queue Position {} - still waiting\n\
echo Gate Position {} - still waiting\n\
chain {base}/api/queue/poll/{entry_id}\n",
g.position
))
@@ -1009,34 +605,27 @@ async fn api_queue_poll(
}
#[derive(Debug, Deserialize)]
struct QueueAssignBody {
struct GateAssignBody {
/// Boot entry id (from `BootEntry::id`). Same one used in
/// `/boot/<id>.ipxe`.
target: String,
/// Queue entry ids to assign. Empty = assign to all currently queued clients.
/// Gate ids to assign. Empty = assign to all currently queued gates.
entry_ids: Vec<String>,
}
async fn api_queue_assign(
State(state): State<AppState>,
Json(body): Json<QueueAssignBody>,
Json(body): Json<GateAssignBody>,
) -> Json<serde_json::Value> {
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 {
body.entry_ids
};
// Guard: target must exist as a BootEntry id.
let found = state
.iso_store
.list()
.into_iter()
.any(|i| i.boot_entries.iter().any(|e| e.id == body.target));
let found = state.iso_store.list().into_iter().any(|i| {
i.boot_entries.iter().any(|e| e.id == body.target)
});
if !found {
return Json(json!({ "ok": false, "error": "unknown target" }));
}
@@ -1060,7 +649,10 @@ async fn api_nfs_list(State(state): State<AppState>) -> Json<serde_json::Value>
Json(json!({ "mounts": state.nfs.list() }))
}
async fn api_nfs_add(State(state): State<AppState>, Json(req): Json<NfsAddRequest>) -> Response {
async fn api_nfs_add(
State(state): State<AppState>,
Json(req): Json<NfsAddRequest>,
) -> Response {
match state.nfs.add(req).await {
Ok(m) => (StatusCode::CREATED, Json(m)).into_response(),
// Anything from the manager surfaces as a user-fixable validation
@@ -1071,14 +663,20 @@ async fn api_nfs_add(State(state): State<AppState>, Json(req): Json<NfsAddReques
}
}
async fn api_nfs_remove(State(state): State<AppState>, AxumPath(id): AxumPath<String>) -> Response {
async fn api_nfs_remove(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Response {
match state.nfs.remove(&id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
}
async fn api_nfs_scan(State(state): State<AppState>, AxumPath(id): AxumPath<String>) -> Response {
async fn api_nfs_scan(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Response {
match state.nfs.rescan(&id).await {
Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(),
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
@@ -1173,12 +771,6 @@ async fn api_hosts_remove(
}
}
// ─── Boot event log ───────────────────────────────────────────────────────
async fn api_boot_log(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!({ "events": state.boot_log.list() }))
}
// ─── Prometheus metrics ───────────────────────────────────────────────────
async fn api_metrics(State(state): State<AppState>) -> Response {
@@ -1191,14 +783,11 @@ async fn api_metrics(State(state): State<AppState>) -> Response {
state
.metrics
.set_client_count(state.clients.list().len() as u64);
let queue_entries = state.queue.list();
let imaging = queue_entries
.iter()
.filter(|entry| entry.assigned_target.is_some())
.count();
let gates = state.queue.list();
let imaging = gates.iter().filter(|g| g.assigned_target.is_some()).count();
state
.metrics
.set_queue_counts(queue_entries.len() as u64, imaging as u64);
.set_queue_counts(gates.len() as u64, imaging as u64);
state
.metrics
.set_nfs_active(state.nfs.list().iter().filter(|m| m.mounted).count() as u64);
@@ -1222,39 +811,25 @@ mod tests {
#[test]
fn range_full() {
let (s, e, p) = parse_range(None, 1000).unwrap();
let (s, e, p) = parse_range(None, 1000);
assert_eq!((s, e, p), (0, 999, false));
}
#[test]
fn range_open_ended() {
let h = HeaderValue::from_static("bytes=500-");
let (s, e, p) = parse_range(Some(&h), 1000).unwrap();
let (s, e, p) = parse_range(Some(&h), 1000);
assert_eq!((s, e, p), (500, 999, true));
}
#[test]
fn range_suffix() {
let h = HeaderValue::from_static("bytes=-100");
let (s, e, p) = parse_range(Some(&h), 1000).unwrap();
let (s, e, p) = parse_range(Some(&h), 1000);
assert_eq!((s, e, p), (900, 999, true));
}
#[test]
fn range_explicit() {
let h = HeaderValue::from_static("bytes=10-99");
let (s, e, p) = parse_range(Some(&h), 1000).unwrap();
let (s, e, p) = parse_range(Some(&h), 1000);
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);
}
}
+32 -256
View File
@@ -23,8 +23,8 @@
//! There is intentionally no UI path to upload a custom `.ipxe` script.
use openpxe_core::{Settings, TimeoutAction};
use openpxe_iso_store::introspect::DistroFamily;
use openpxe_iso_store::{BootEntry, BootKind, IsoMeta};
use openpxe_iso_store::introspect::DistroFamily;
use std::fmt::Write as _;
/// Top-level OpenPXE boot menu. Serialized identically for BIOS and UEFI
@@ -49,15 +49,9 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
let _ = writeln!(s, "set cls ${{esc:string}}[2J");
let _ = writeln!(s, ":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 --gap -- ----------------------- Installers -----------------------"
);
let _ = writeln!(s, "item --gap -- ----------------------- Installers -----------------------");
if has_family(isos, is_linux_family) {
let _ = writeln!(s, "item linux Linux Installers >");
} else {
@@ -70,15 +64,9 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
} else {
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 --gap -- ---------------------- Queued Deployment ---------------------"
);
let _ = writeln!(s, "item --gap -- ---------------------- Queued Deployment ---------------------");
let _ = writeln!(s, "item queue Queued Deployment (join queue)");
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key x exit Exit iPXE");
@@ -86,39 +74,18 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
if matches!(settings.timeout_action, TimeoutAction::Stay) {
let _ = writeln!(s, "choose --default {default_item} target || goto menu");
} else {
let _ = writeln!(
s,
"choose --default {default_item} --timeout {timeout_ms} target || goto menu"
);
let _ = writeln!(s, "choose --default {default_item} --timeout {timeout_ms} target || goto menu");
}
// iPXE's `||` is strict about what follows. Each test uses `goto menu`
// as the fallthrough target so the parser never sees a bare `||` with
// trailing whitespace — some iPXE builds reject that.
let _ = writeln!(
s,
"iseq ${{target}} local && chain {base}/boot/_local.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} linux && chain {base}/boot/_linux_menu.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} windows && chain {base}/boot/_windows_menu.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} tools && chain {base}/boot/_tools_menu.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} queue && chain {base}/boot/_queue.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} exit && exit || goto menu"
);
let _ = writeln!(s, "iseq ${{target}} local && chain {base}/boot/_local.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} linux && chain {base}/boot/_linux_menu.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} windows && chain {base}/boot/_windows_menu.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} tools && chain {base}/boot/_tools_menu.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} queue && chain {base}/boot/_queue.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} exit && exit || goto menu");
let _ = writeln!(s, "goto menu");
s
}
@@ -128,44 +95,25 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
#[must_use]
pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) -> String {
let base = base_url.trim_end_matches('/');
let title = if is_windows {
"Windows Installers"
} else {
"Linux Installers"
};
let title = if is_windows { "Windows Installers" } else { "Linux Installers" };
let label = if is_windows { "windows" } else { "linux" };
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "set base-url {base}");
let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu OpenPXE - {title}");
let filter: fn(DistroFamily) -> bool = if is_windows {
is_windows_family
} else {
is_linux_family
};
let filter: fn(DistroFamily) -> bool =
if is_windows { is_windows_family } else { is_linux_family };
let mut count = 0;
for iso in isos {
if !filter(iso.introspection.family) {
continue;
}
if !filter(iso.introspection.family) { continue; }
for entry in &iso.boot_entries {
let size_label = fmt_size_mib(iso.size_bytes);
let key = hotkey_for_index(count);
// 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!(
s,
"item {}{} {}[{:>6}] {}",
s, "item {}{} [{:>6}] {}",
key,
entry.id,
lock,
size_label,
escape_label(&entry.title),
);
@@ -178,18 +126,8 @@ pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) ->
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key b back < Back to main menu");
let _ = writeln!(s, "choose target || goto menu");
let _ = writeln!(
s,
"iseq ${{target}} back && chain {base}/boot.ipxe || goto menu"
);
// Pass `?mac=${mac}` so the per-entry handler can record the booting
// client into the Host log (v0.4.0). iPXE substitutes `${mac}` before
// the HTTP fetch; if the firmware can't resolve it the literal
// `${mac}` is sent and the server treats it as "unknown".
let _ = writeln!(
s,
"chain {base}/boot/${{target}}.ipxe?mac=${{mac}} || goto menu"
);
let _ = writeln!(s, "iseq ${{target}} back && chain {base}/boot.ipxe || goto menu");
let _ = writeln!(s, "chain {base}/boot/${{target}}.ipxe || goto menu");
s
}
@@ -228,30 +166,12 @@ pub fn render_tools_menu(base_url: &str) -> String {
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key b back < Back to main menu");
let _ = writeln!(s, "choose target || goto menu");
let _ = writeln!(
s,
"iseq ${{target}} util && chain {base}/boot/_util.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} shell && chain {base}/boot/_shell.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} nic && chain {base}/boot/_nic.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} reboot && reboot || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} firmware && exit 0 || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} back && chain {base}/boot.ipxe || goto menu"
);
let _ = writeln!(s, "iseq ${{target}} util && chain {base}/boot/_util.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} shell && chain {base}/boot/_shell.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} nic && chain {base}/boot/_nic.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} reboot && reboot || goto menu");
let _ = writeln!(s, "iseq ${{target}} firmware && exit 0 || goto menu");
let _ = writeln!(s, "iseq ${{target}} back && chain {base}/boot.ipxe || goto menu");
let _ = writeln!(s, "goto menu");
s
}
@@ -265,15 +185,8 @@ pub fn render_local_hdd(base_url: &str) -> String {
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "# Boot from Local HDD - platform-sensitive");
let _ = writeln!(
s,
"iseq ${{platform}} pcbios && sanboot --no-describe --drive 0x80 || goto uefi"
);
let _ = writeln!(s, ":uefi");
let _ = writeln!(
s,
"# UEFI path: fall through to the firmware's next boot entry"
);
let _ = writeln!(s, "iseq ${{platform}} pcbios && sanboot --no-describe --drive 0x80 || ");
let _ = writeln!(s, "# UEFI path: fall through to the firmware's next boot entry");
let _ = writeln!(s, "exit 0");
let _ = writeln!(s, "# If the above exit returns, loop back to the main menu");
let _ = writeln!(s, "chain {base}/boot.ipxe");
@@ -294,14 +207,8 @@ pub fn render_util(base_url: &str) -> String {
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item back < Back");
let _ = writeln!(s, "choose target || goto menu");
let _ = writeln!(
s,
"iseq ${{target}} memtest && chain {base}/ipxe/memtest.bin || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} back && chain {base}/boot/_tools_menu.ipxe || goto menu"
);
let _ = writeln!(s, "iseq ${{target}} memtest && chain {base}/ipxe/memtest.bin || ");
let _ = writeln!(s, "iseq ${{target}} back && chain {base}/boot/_tools_menu.ipxe || ");
let _ = writeln!(s, "goto menu");
s
}
@@ -348,10 +255,7 @@ pub fn render_queue_entry(base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let mut s = String::new();
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...");
// 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
@@ -368,11 +272,7 @@ pub fn render_entry(entry: &BootEntry, settings: &Settings, base_url: &str) -> S
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "set base-url {base}");
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);
if !settings.extra_kernel_args.trim().is_empty() {
cmdline.push(' ');
@@ -423,129 +323,5 @@ fn has_family(isos: &[IsoMeta], pred: fn(DistroFamily) -> bool) -> bool {
}
fn escape_label(s: &str) -> String {
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...");
// Carry `mac=${mac}` alongside the token so a successful unlock
// records the actual client MAC into the Host log (v0.4.0). On
// older iPXE that can't resolve `${mac}` the server just stores it
// as "unknown" rather than refusing to boot.
let _ = writeln!(
s,
"chain {base}/boot/{entry_id}.ipxe?token=${{password:uristring}}&mac=${{mac}} \
|| 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}"
);
}
}
}
s.chars().map(|c| match c { '\n' | '\r' => ' ', c => c }).collect()
}
+10 -33
View File
@@ -31,9 +31,7 @@ pub fn lookup(iso_path: &Path, in_iso_path: &str) -> Option<FileLocation> {
.split('/')
.filter(|c| !c.is_empty())
.collect();
if components.is_empty() {
return None;
}
if components.is_empty() { return None; }
walk(&mut f, root.offset, root.length, &components)
}
@@ -42,16 +40,11 @@ fn read_root_directory(f: &mut std::fs::File) -> Option<FileLocation> {
let mut pvd = [0u8; 2048];
f.seek(SeekFrom::Start(16 * SECTOR)).ok()?;
f.read_exact(&mut pvd).ok()?;
if pvd[0] != 0x01 || &pvd[1..6] != b"CD001" {
return None;
}
if pvd[0] != 0x01 || &pvd[1..6] != b"CD001" { return None; }
// Root directory record is at offset 156, length 34.
let rec = &pvd[156..156 + 34];
let (offset, length) = parse_dir_record_ext(rec)?;
Some(FileLocation {
offset: offset * SECTOR,
length,
})
Some(FileLocation { offset: offset * SECTOR, length })
}
/// Walk components down the directory tree starting at `dir_offset`.
@@ -73,29 +66,21 @@ fn walk(
if len == 0 {
// Padding to sector boundary.
let next = (i / SECTOR as usize + 1) * SECTOR as usize;
if next <= i {
break;
}
if next <= i { break; }
i = next;
continue;
}
if i + len > dir.len() {
break;
}
if i + len > dir.len() { break; }
let rec = &dir[i..i + len];
let name = dir_record_name(rec);
let is_dir = (rec.get(25).copied().unwrap_or(0) & 0x02) != 0;
// Skip "." (0x00) and ".." (0x01) pseudo-entries.
let is_pseudo = matches!(rec.get(32).copied(), Some(1))
&& rec.get(33).copied() == Some(0x00)
let is_pseudo = matches!(rec.get(32).copied(), Some(1)) && rec.get(33).copied() == Some(0x00)
|| matches!(rec.get(32).copied(), Some(1)) && rec.get(33).copied() == Some(0x01);
if !is_pseudo && name.eq_ignore_ascii_case(target) {
let (child_off, child_len) = parse_dir_record_ext(rec)?;
if rest.is_empty() && !is_dir {
return Some(FileLocation {
offset: child_off * SECTOR,
length: child_len,
});
return Some(FileLocation { offset: child_off * SECTOR, length: child_len });
} else if !rest.is_empty() && is_dir {
return walk(f, child_off * SECTOR, child_len, rest);
}
@@ -109,9 +94,7 @@ fn walk(
/// Layout per ISO9660: bytes 2..10 extent LBA (LE+BE duplicate), 10..18
/// data length (LE+BE duplicate). We trust the little-endian copy.
fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> {
if rec.len() < 34 {
return None;
}
if rec.len() < 34 { return None; }
let lba = u32::from_le_bytes(rec[2..6].try_into().ok()?) as u64;
let len = u32::from_le_bytes(rec[10..14].try_into().ok()?) as u64;
Some((lba, len))
@@ -121,15 +104,9 @@ fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> {
/// `;1` version suffix.
fn dir_record_name(rec: &[u8]) -> String {
let name_len = *rec.get(32).unwrap_or(&0) as usize;
if name_len == 0 || rec.len() < 33 + name_len {
return String::new();
}
if name_len == 0 || rec.len() < 33 + name_len { return String::new(); }
let raw = &rec[33..33 + name_len];
let s = String::from_utf8_lossy(raw).to_string();
// Strip `;N` version suffix.
if let Some(i) = s.rfind(';') {
s[..i].to_string()
} else {
s
}
if let Some(i) = s.rfind(';') { s[..i].to_string() } else { s }
}
+5 -9
View File
@@ -36,11 +36,9 @@ pub async fn stream(
let rx = state.log_bus.subscribe();
let live = BroadcastStream::new(rx).map(|res| match res {
Ok(line) => Ok(Event::default().data(line_json(&line))),
Err(tokio_stream::wrappers::errors::BroadcastStreamRecvError::Lagged(n)) => {
Ok(Event::default()
Err(tokio_stream::wrappers::errors::BroadcastStreamRecvError::Lagged(n)) => Ok(Event::default()
.event("lagged")
.data(json!({ "skipped": n }).to_string()))
}
.data(json!({ "skipped": n }).to_string())),
});
Sse::new(recent_stream.chain(live))
@@ -57,11 +55,9 @@ pub async fn recent(State(state): State<AppState>) -> Json<serde_json::Value> {
/// keep streaming new lines as they arrive).
pub async fn clear(State(state): State<AppState>) -> Json<serde_json::Value> {
state.log_bus.clear();
state.log_bus.push(
"info",
"openpxe::terminal",
"log buffer cleared by operator",
);
state
.log_bus
.push("info", "openpxe::terminal", "log buffer cleared by operator");
Json(json!({ "ok": true }))
}
+1 -7
View File
@@ -1,6 +1,4 @@
use openpxe_core::{
BootLog, ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore,
};
use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore};
use openpxe_iso_store::{IsoStore, NfsManager, SmbManager};
use std::sync::Arc;
use time::OffsetDateTime;
@@ -15,10 +13,6 @@ pub struct AppState {
/// these MACs requests `/boot.ipxe`, we chain straight to the
/// configured target instead of rendering the menu.
pub hosts: HostBindings,
/// Persistent boot-event log surfaced under the Hosts tab. Records
/// every `/boot/<entry>.ipxe` chain that goes on to serve a script
/// (i.e. an image actually starting to install on a machine).
pub boot_log: BootLog,
/// Lock-free metrics counters surfaced at `/metrics` in Prometheus
/// text format. Cheap to clone (handles to atomics).
pub metrics: Metrics,
+47 -73
View File
@@ -31,17 +31,12 @@ pub async fn run_command(
) -> impl IntoResponse {
let line = req.command.trim();
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
// 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);
if argv.is_empty() {
@@ -60,11 +55,7 @@ pub async fn run_command(
// so reading the live tail tells the same story as scrolling the
// terminal pane.
let mirror = if output.len() > 1024 {
format!(
"{}\n... ({} bytes truncated)",
&output[..1024],
output.len() - 1024
)
format!("{}\n... ({} bytes truncated)", &output[..1024], output.len() - 1024)
} else {
output.clone()
};
@@ -87,7 +78,7 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
"status" => Ok(status_text(state)),
"isos" | "images" => Ok(isos_text(state)),
"clients" => Ok(clients_text(state)),
"queue" => queue_command(state, tail).await,
"queue" => gate_command(state, tail).await,
"nfs" => nfs_command(state, tail).await,
"smb" => smb_command(state, tail).await,
"log" => log_command(state, tail),
@@ -105,7 +96,7 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
fn status_text(s: &AppState) -> String {
let isos = s.iso_store.list();
let clients = s.clients.list();
let queue_entries = s.queue.list();
let gates = s.queue.list();
let smb = s.smb.as_ref().map(|m| m.snapshot());
let nfs = s.nfs.list();
let nfs_active = nfs.iter().filter(|m| m.mounted).count();
@@ -121,23 +112,13 @@ fn status_text(s: &AppState) -> String {
nfs mounts: {n_total} configured ({n_active} active)\n",
ver = env!("CARGO_PKG_VERSION"),
base = s.public_base_url,
nic = if s.nic_name.is_empty() {
"?"
} else {
s.nic_name.as_str()
},
nic = if s.nic_name.is_empty() { "?" } else { s.nic_name.as_str() },
up = uptime_string(s),
n_isos = isos.len(),
n_local = isos
.iter()
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Local))
.count(),
n_nfs = isos
.iter()
.filter(|i| !matches!(i.source, openpxe_iso_store::IsoSource::Local))
.count(),
n_local = isos.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_entries = queue_entries.len(),
n_entries = gates.len(),
smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")),
n_total = nfs.len(),
n_active = nfs_active,
@@ -178,7 +159,11 @@ fn clients_text(s: &AppState) -> String {
return "(no clients yet)".into();
}
let mut out = String::new();
let _ = writeln!(out, "{:<19} {:<16} {:<8} LAST SEEN", "MAC", "IP", "EVENTS");
let _ = writeln!(
out,
"{:<19} {:<16} {:<8} LAST SEEN",
"MAC", "IP", "EVENTS"
);
for c in clients {
let ip = c.last_ip.map_or_else(|| "-".into(), |i| i.to_string());
let _ = writeln!(
@@ -195,35 +180,35 @@ fn clients_text(s: &AppState) -> String {
out
}
// ── queue ──────────────────────────────────────────────────────────────
// ── gate ──────────────────────────────────────────────────────────────
// `async` for symmetry with the other dispatch helpers — queue operations
// `async` for symmetry with the other dispatch helpers — gate operations
// are sync today but might grow to await on a database in a future phase.
#[allow(clippy::unused_async)]
async fn queue_command(s: &AppState, args: &[String]) -> Result<String, String> {
async fn gate_command(s: &AppState, args: &[String]) -> Result<String, String> {
match args.first().map(String::as_str) {
None | Some("list") => {
let entries = s.queue.list();
if entries.is_empty() {
return Ok("(queue empty)".into());
let gs = s.queue.list();
if gs.is_empty() {
return Ok("(no gates)".into());
}
let mut out = String::new();
for entry in entries {
for g in gs {
let _ = writeln!(
out,
"#{:<3} {:<19} {:<16} target={}",
entry.position,
entry.mac,
entry.id,
entry.assigned_target.unwrap_or_else(|| "-".into())
g.position,
g.mac,
g.id,
g.assigned_target.unwrap_or_else(|| "-".into())
);
}
Ok(out)
}
Some("assign-all") => {
let target = args
.get(1)
.ok_or_else(|| "usage: queue assign-all <iso_boot_entry_id>".to_string())?;
let target = args.get(1).ok_or_else(|| {
"usage: gate assign-all <iso_boot_entry_id>".to_string()
})?;
let found = s
.iso_store
.list()
@@ -234,32 +219,30 @@ async fn queue_command(s: &AppState, args: &[String]) -> Result<String, String>
}
let ids: Vec<_> = s.queue.list().into_iter().map(|g| g.id).collect();
let n = s.queue.assign(&ids, target);
Ok(format!("assigned {n} queue entries -> {target}"))
Ok(format!("assigned {n} gates -> {target}"))
}
Some("assign") => {
let entry_id = args
.get(1)
.ok_or_else(|| "usage: queue assign <entry_id> <iso_boot_entry_id>".to_string())?;
.ok_or_else(|| "usage: gate assign <entry_id> <iso_boot_entry_id>".to_string())?;
let target = args
.get(2)
.ok_or_else(|| "usage: queue assign <entry_id> <iso_boot_entry_id>".to_string())?;
.ok_or_else(|| "usage: gate assign <entry_id> <iso_boot_entry_id>".to_string())?;
let n = s.queue.assign(std::slice::from_ref(entry_id), target);
if n == 0 {
return Err(format!("no such queue entry: {entry_id}"));
return Err(format!("no such gate: {entry_id}"));
}
Ok(format!("assigned 1 queue entry -> {target}"))
Ok(format!("assigned 1 gate -> {target}"))
}
Some("release") => {
let entry_id = args
.get(1)
.ok_or_else(|| "usage: queue release <entry_id>".to_string())?;
let entry_id = args.get(1).ok_or_else(|| "usage: gate release <entry_id>".to_string())?;
match s.queue.release(entry_id) {
Some(_) => Ok(format!("released {entry_id}")),
None => Err(format!("no such queue entry: {entry_id}")),
None => Err(format!("no such gate: {entry_id}")),
}
}
Some(other) => Err(format!(
"unknown queue subcommand: {other}\ntry: queue [list|assign-all|assign|release]"
"unknown gate subcommand: {other}\ntry: gate [list|assign-all|assign|release]"
)),
}
}
@@ -311,9 +294,7 @@ async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
let version = match args.get(2).map(String::as_str) {
Some("v3") => openpxe_iso_store::NfsVersion::V3,
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 req = openpxe_iso_store::NfsAddRequest {
@@ -328,18 +309,14 @@ async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
}
}
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 {
Ok(()) => Ok(format!("unmounted {id}")),
Err(e) => Err(format!("unmount failed: {e}")),
}
}
Some("scan") => {
let id = args
.get(1)
.ok_or_else(|| "usage: nfs scan <id>".to_string())?;
let id = args.get(1).ok_or_else(|| "usage: nfs scan <id>".to_string())?;
match s.nfs.rescan(id).await {
Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
Err(e) => Err(format!("scan failed: {e}")),
@@ -391,7 +368,10 @@ fn log_command(s: &AppState, args: &[String]) -> Result<String, String> {
Ok("log buffer cleared".into())
}
Some("tail") => {
let n: usize = args.get(1).and_then(|v| v.parse().ok()).unwrap_or(20);
let n: usize = args
.get(1)
.and_then(|v| v.parse().ok())
.unwrap_or(20);
let lines = s.log_bus.recent();
let start = lines.len().saturating_sub(n);
let mut out = String::new();
@@ -482,10 +462,10 @@ OpenPXE terminal — available commands:
isos list registered ISOs
clients list PXE clients seen this session
queue list list queued clients
queue assign <entry_id> <target> assign one queued client to a boot entry
queue assign-all <target> assign every waiting client
queue release <entry_id> release one queued client
gate list list gated-deployment queue
gate assign <entry_id> <target> assign one gate to a boot entry
gate assign-all <target> assign every waiting gate
gate release <entry_id> release one gate
nfs list list NFS mounts
nfs mount <s>:<e> [v3|v41] [ro|rw] add and mount an NFS share
@@ -541,10 +521,4 @@ mod tests {
assert_eq!(truncate("hi", 10), "hi");
assert_eq!(truncate("longerthanfive", 5), "long…");
}
#[test]
fn help_uses_queue_language() {
assert!(HELP_TEXT.contains("queue list"));
assert!(HELP_TEXT.contains("queued clients"));
}
}
+79 -490
View File
@@ -38,12 +38,13 @@ fn fake_alpine_iso() -> Vec<u8> {
}
fn multipart_iso_body(filename: &str, bytes: &[u8]) -> (String, Vec<u8>) {
let boundary = "----OpenPxeTestBoundary1234";
let boundary = "----PxeForgeTestBoundary1234";
let mut body = Vec::new();
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
body.extend_from_slice(
format!("Content-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\n")
.as_bytes(),
format!(
"Content-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\n"
).as_bytes(),
);
body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
body.extend_from_slice(bytes);
@@ -59,10 +60,7 @@ async fn get(router: &axum::Router, path: &str) -> (StatusCode, Vec<u8>) {
.await
.unwrap();
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)
}
@@ -80,10 +78,7 @@ async fn post_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode
.await
.unwrap();
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)
}
@@ -92,21 +87,19 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
let iso_store = IsoStore::new(dir.path().join("isos"));
iso_store.ensure_dirs().await.unwrap();
let clients = ClientRegistry::new();
let queue = DeploymentQueue::new();
let gates = DeploymentQueue::new();
let settings = SettingsStore::load_or_default(dir.path());
let nfs = NfsManager::new(dir.path(), iso_store.clone());
iso_store.set_nfs_root(nfs.mount_root());
let log_bus = LogBus::new(64);
let hosts = HostBindings::load_or_default(dir.path());
let boot_log = openpxe_core::BootLog::load_or_default(dir.path());
let metrics = Metrics::new();
let state = AppState {
iso_store,
clients,
queue,
queue: gates,
settings,
hosts,
boot_log,
metrics,
smb: None,
nfs,
@@ -160,21 +153,13 @@ async fn upload_introspects_and_generates_boot_entry() {
// Confirm the ISO shows up in the menu.
let (_, menu) = get(&app, "/boot.ipxe").await;
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 = String::from_utf8(linux).unwrap();
assert!(
linux.contains("fake-alpine-linux"),
"linux submenu missing entry:\n{linux}"
);
assert!(
linux.contains("[ 0 MB]") || linux.contains("[ 0 MB]"),
"size label missing in {linux}"
);
assert!(linux.contains("fake-alpine-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.
let (_, entry) = get(&app, "/boot/fake-alpine-linux.ipxe").await;
@@ -193,14 +178,9 @@ async fn iso_range_request_slices_correctly() {
app.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/isos")
.method("POST").uri("/api/isos")
.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.
let res = app
@@ -209,15 +189,10 @@ async fn iso_range_request_slices_correctly() {
Request::builder()
.uri("/iso/fake-alpine.iso")
.header(header::RANGE, "bytes=32768-32773")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
.body(Body::empty()).unwrap())
.await.unwrap();
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[1..6], b"CD001");
}
@@ -230,40 +205,27 @@ async fn queued_deployment_full_flow() {
// Upload an ISO so the target exists.
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
app.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/isos")
.header("content-type", ct)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
.oneshot(Request::builder().method("POST").uri("/api/isos")
.header("content-type", ct).body(Body::from(body)).unwrap())
.await.unwrap();
// Two clients join.
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 s1 = String::from_utf8(join1).unwrap();
let s2 = String::from_utf8(join2).unwrap();
assert!(s1.contains("Queue Position 1"));
assert!(s2.contains("Queue Position 2"));
assert!(s1.contains("Gate Position 1"));
assert!(s2.contains("Gate Position 2"));
let queue1_id = s1
.lines()
.find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/queue/poll/"))
.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();
let gate1_id = s1.lines().find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/queue/poll/"))
.unwrap().to_string();
let gate2_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.
let app2 = app.clone();
let poll_future = tokio::spawn(async move {
let uri = format!("/api/queue/poll/{queue1_id}");
let uri = format!("/api/queue/poll/{gate1_id}");
get(&app2, &uri).await
});
@@ -271,16 +233,13 @@ async fn queued_deployment_full_flow() {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// Operator assigns.
let body = format!(r#"{{"target":"fake-alpine-linux","entry_ids":["{queue2_id}"]}}"#);
let body = format!(r#"{{"target":"fake-alpine-linux","entry_ids":["{gate2_id}"]}}"#);
let (s, b) = post_json(&app, "/api/queue/assign", &body).await;
assert_eq!(s, StatusCode::OK);
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 queue entry 1 too so the background poll wakes.
// Now assign to gate 1 too so the background poll wakes.
let body = r#"{"target":"fake-alpine-linux","entry_ids":[]}"#;
post_json(&app, "/api/queue/assign", body).await;
@@ -292,23 +251,14 @@ async fn queued_deployment_full_flow() {
"poll response should chain the boot script:\n{poll_s}"
);
// Retry-on-error fallback must be present.
assert!(
poll_s.contains("|| chain http://127.0.0.1/api/queue/poll/"),
"retry fallback missing"
);
assert!(poll_s.contains("|| chain http://127.0.0.1/api/queue/poll/"),
"retry fallback missing");
// Bad target must be rejected.
let (_, bad) = post_json(
&app,
"/api/queue/assign",
r#"{"target":"does-not-exist","entry_ids":[]}"#,
)
.await;
let (_, bad) = post_json(&app, "/api/queue/assign",
r#"{"target":"does-not-exist","entry_ids":[]}"#).await;
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]
@@ -322,9 +272,8 @@ async fn settings_put_persists_across_reads() {
"smb_host_override": "",
"extra_kernel_args": "console=ttyS0",
"default_local_hdd": true,
"queue_wait_max_secs": 0
})
.to_string();
"gate_wait_max_secs": 0
}).to_string();
let res = app
.clone()
@@ -334,8 +283,7 @@ async fn settings_put_persists_across_reads() {
.uri("/api/settings")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NO_CONTENT);
@@ -349,14 +297,10 @@ async fn settings_put_persists_across_reads() {
// And the menu should now use the new timeout.
let (_, menu) = get(&app, "/boot.ipxe").await;
let menu = String::from_utf8(menu).unwrap();
assert!(
menu.contains("--timeout 42000"),
"menu should reflect 42s timeout:\n{menu}"
);
assert!(
menu.contains("--default local"),
"menu should default to local:\n{menu}"
);
assert!(menu.contains("--timeout 42000"),
"menu should reflect 42s timeout:\n{menu}");
assert!(menu.contains("--default local"),
"menu should default to local:\n{menu}");
}
#[tokio::test]
@@ -365,22 +309,14 @@ async fn reboot_and_firmware_exit_in_tools_menu() {
let app = build_router(state);
let (_, tools) = get(&app, "/boot/_tools_menu.ipxe").await;
let tools = String::from_utf8(tools).unwrap();
assert!(
tools.contains("Reboot Computer"),
"tools menu missing Reboot item:\n{tools}"
);
assert!(
tools.contains("Exit and continue BIOS boot"),
"tools menu missing firmware-exit item:\n{tools}"
);
assert!(
tools.contains("&& reboot"),
"reboot command not wired:\n{tools}"
);
assert!(
tools.contains("&& exit 0"),
"firmware exit command not wired:\n{tools}"
);
assert!(tools.contains("Reboot Computer"),
"tools menu missing Reboot item:\n{tools}");
assert!(tools.contains("Exit and continue BIOS boot"),
"tools menu missing firmware-exit item:\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]
@@ -398,15 +334,11 @@ async fn ui_assets_served_offline() {
let res = app
.clone()
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
.await
.unwrap();
.await.unwrap();
assert_eq!(res.status(), StatusCode::OK, "{path} not 200");
let got = res
.headers()
.get(header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap();
let got = res.headers()
.get(header::CONTENT_TYPE).unwrap()
.to_str().unwrap();
assert!(got.starts_with(ct), "{path} ct={got}, expected {ct}");
}
}
@@ -416,38 +348,20 @@ async fn no_external_urls_in_generated_ipxe() {
// Sanity check that nothing we serve points off-server.
let (state, _dir) = build_state().await;
let app = build_router(state);
for path in [
"/boot.ipxe",
"/boot/_tools_menu.ipxe",
"/boot/_linux_menu.ipxe",
"/boot/_shell.ipxe",
"/boot/_nic.ipxe",
"/boot/_local.ipxe",
] {
for path in ["/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 s = String::from_utf8(body).unwrap();
// The only URLs we should emit are relative to our own public_base_url.
for url in [
"github.com",
"googleapis",
"cdn.",
"cdnjs",
"unpkg",
"jsdelivr",
] {
assert!(
!s.contains(url),
"{path} references external host {url}:\n{s}"
);
for url in ["github.com", "googleapis", "cdn.", "cdnjs", "unpkg", "jsdelivr"] {
assert!(!s.contains(url), "{path} references external host {url}:\n{s}");
}
// Confirm URLs are all ours.
for line in s.lines() {
if let Some(idx) = line.find("http://") {
let rest = &line[idx..];
assert!(
rest.starts_with("http://127.0.0.1"),
"{path} references non-public-base URL: {line}"
);
assert!(rest.starts_with("http://127.0.0.1"),
"{path} references non-public-base URL: {line}");
}
}
}
@@ -470,10 +384,7 @@ async fn nfs_add_with_bad_export_is_rejected() {
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
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]
@@ -502,10 +413,7 @@ async fn terminal_help_and_status_round_trip() {
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).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}");
// Unknown command -> ok=false plus help hint.
@@ -526,10 +434,7 @@ async fn log_recent_returns_buffered_lines() {
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
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.
for l in lines {
for k in ["timestamp", "level", "target", "message"] {
@@ -574,7 +479,7 @@ async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
.body(Body::from(
r#"{"boot_menu_timeout_secs":600,"timeout_action":"queued_deployment",
"windows_enabled":false,"smb_host_override":"","extra_kernel_args":"",
"default_local_hdd":true,"queue_wait_max_secs":0,"dns_server":""}"#
"default_local_hdd":true,"gate_wait_max_secs":0,"dns_server":""}"#
.to_string(),
))
.unwrap(),
@@ -599,9 +504,7 @@ async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
.await
.unwrap();
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();
assert_eq!(meta["introspection"]["family"], "windows_pe");
assert!(
@@ -631,10 +534,7 @@ async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
assert_eq!(s, StatusCode::OK);
let script = String::from_utf8(body).unwrap();
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"] {
assert!(
script.contains(&format!("initrd --name {tag}")),
@@ -644,12 +544,8 @@ 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.
let lower = script.to_lowercase();
for forbidden in [
"bcdedit",
"testsigning",
"certutil",
"test-signed",
"httpdisk",
"/set testsigning",
"bcdedit", "testsigning", "certutil", "test-signed",
"httpdisk", "/set testsigning",
] {
assert!(
!lower.contains(forbidden),
@@ -714,25 +610,16 @@ async fn metrics_endpoint_emits_prometheus_format() {
let res = app
.clone()
.oneshot(
Request::builder()
.uri("/metrics")
.body(Body::empty())
.unwrap(),
)
.oneshot(Request::builder().uri("/metrics").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let ct = res
.headers()
.get(header::CONTENT_TYPE)
.unwrap()
.to_str()
.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 ct = res.headers().get(header::CONTENT_TYPE).unwrap().to_str().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();
// Spot-check the must-have metric families.
for name in [
@@ -746,7 +633,10 @@ async fn metrics_endpoint_emits_prometheus_format() {
assert!(body.contains(name), "missing metric {name} in:\n{body}");
}
// Each name appears exactly once as a `# TYPE` declaration.
for name in ["openpxe_dhcp_replies_total", "openpxe_iso_count"] {
for name in [
"openpxe_dhcp_replies_total",
"openpxe_iso_count",
] {
let count = body.matches(&format!("# TYPE {name}")).count();
assert_eq!(count, 1, "{name} TYPE line appears {count} times");
}
@@ -782,304 +672,3 @@ async fn network_endpoint_exposes_dns_round_trip() {
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
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);
}
#[tokio::test]
async fn boot_log_records_entry_serve_with_mac() {
// End-to-end: upload an ISO, fetch the entry's boot script with a
// MAC query param, then GET /api/boot-log and assert the event is
// there with the supplied mac.
let (state, _dir) = build_state().await;
let app = build_router(state);
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
let upload = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/isos")
.header("content-type", ct)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(upload.status(), StatusCode::CREATED);
// Fetch the per-entry script with ?mac=...
let (s, _) = get(
&app,
"/boot/fake-alpine-linux.ipxe?mac=AA:BB:CC:00:00:09",
)
.await;
assert_eq!(s, StatusCode::OK);
// The boot log should now contain exactly one entry, with the
// normalized MAC and our target id.
let (s, body) = get(&app, "/api/boot-log").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
let events = v["events"].as_array().expect("events");
assert_eq!(events.len(), 1);
let ev = &events[0];
assert_eq!(ev["target_id"], "fake-alpine-linux");
assert_eq!(ev["mac"], "aa:bb:cc:00:00:09"); // normalized
// Title should include the filename and entry title.
let title = ev["target_title"].as_str().unwrap();
assert!(title.contains("fake-alpine.iso"), "title was {title}");
}
#[tokio::test]
async fn boot_log_endpoint_empty_when_no_boots() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, body) = get(&app, "/api/boot-log").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(v["events"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn boot_log_does_not_record_reserved_menu_targets() {
// Reserved targets (_local, _queue, …) are operator console actions,
// not imaging events. The Hosts log skips them so it stays focused
// on "what got installed where".
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
// Bind a MAC to the _local shortcut and hit /boot.ipxe.
let body = r#"{"mac":"aa:bb:cc:00:00:11","target":"_local","label":"q"}"#;
let (s, _) = post_json(&app, "/api/hosts", body).await;
assert_eq!(s, StatusCode::CREATED);
let (s, _) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:11").await;
assert_eq!(s, StatusCode::OK);
let (_, body) = get(&app, "/api/boot-log").await;
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
v["events"].as_array().unwrap().is_empty(),
"reserved targets should not appear in boot log; got {v}"
);
}
#[tokio::test]
async fn upload_rejects_non_iso_filename_with_clear_message() {
// Sanity for the upload-logging path: a wrong extension should land
// a 400 with the human message rather than silently being eaten by
// the multipart loop. (No iso ends up in the store either.)
let (state, _dir) = build_state().await;
let app = build_router(state);
let (ct, body) = multipart_iso_body("not-an-iso.txt", b"hello world");
let res = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/isos")
.header("content-type", ct)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
let text = std::str::from_utf8(&body).unwrap();
assert!(text.contains("only .iso uploads accepted"), "got: {text}");
}
+1 -3
View File
@@ -51,9 +51,7 @@ pub fn asset_slice(name: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
/// Enumerate embedded asset filenames. Useful for startup logging so the
/// operator can immediately tell which architectures will work.
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.
-1
View File
@@ -20,7 +20,6 @@ thiserror.workspace = true
anyhow.workspace = true
sha2.workspace = true
hex.workspace = true
bcrypt.workspace = true
uuid.workspace = true
time.workspace = true
parking_lot.workspace = true
+11 -38
View File
@@ -78,9 +78,7 @@ pub fn introspect(path: &Path) -> IntrospectionReport {
let mut haystack = Vec::with_capacity(scan_bytes.min(32 * 1024 * 1024));
while read_total < scan_bytes {
let n = f.read(&mut buf).unwrap_or(0);
if n == 0 {
break;
}
if n == 0 { break; }
haystack.extend_from_slice(&buf[..n]);
read_total += n;
}
@@ -109,11 +107,8 @@ fn family_from_label(label: &str) -> DistroFamily {
let l = label.to_ascii_lowercase();
if l.contains("ubuntu") || l.contains("debian") || l.contains("mint") {
DistroFamily::DebianUbuntu
} else if l.contains("rhel")
|| l.contains("centos")
|| l.contains("fedora")
|| l.contains("rocky")
|| l.contains("alma")
} else if l.contains("rhel") || l.contains("centos") || l.contains("fedora")
|| l.contains("rocky") || l.contains("alma")
{
DistroFamily::RhelFedora
} else if l.contains("suse") || l.contains("opensuse") {
@@ -132,30 +127,17 @@ fn family_from_label(label: &str) -> DistroFamily {
fn guess_kernel_initrd(family: DistroFamily) -> (Option<&'static str>, Vec<&'static str>) {
match family {
DistroFamily::DebianUbuntu => (Some("/casper/vmlinuz"), vec!["/casper/initrd"]),
DistroFamily::RhelFedora => (
Some("/images/pxeboot/vmlinuz"),
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::RhelFedora => (Some("/images/pxeboot/vmlinuz"), 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::WindowsPe | DistroFamily::Unknown => (None, Vec::new()),
}
}
fn contains_ascii(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || haystack.len() < needle.len() {
return false;
}
haystack
.windows(needle.len())
.any(|w| w.eq_ignore_ascii_case(needle))
if needle.is_empty() || haystack.len() < needle.len() { return false; }
haystack.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle))
}
#[cfg(test)]
@@ -164,18 +146,9 @@ mod tests {
#[test]
fn label_matching() {
assert_eq!(
family_from_label("Ubuntu 24.04"),
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("Ubuntu 24.04"), 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("weird-custom"), DistroFamily::Unknown);
}
+1 -3
View File
@@ -27,7 +27,5 @@ pub use entry::{BootEntry, BootKind, KernelArgs};
pub use introspect::{DistroFamily, IntrospectionReport};
pub use nfs::{NfsAddRequest, NfsManager, NfsMount, NfsVersion};
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};
+9 -3
View File
@@ -36,8 +36,8 @@
use crate::introspect::{introspect, IntrospectionReport};
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
use openpxe_core::{Error, Result};
use parking_lot::Mutex;
use openpxe_core::{Error, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
@@ -403,8 +403,14 @@ impl NfsManager {
mount_id: m.id.clone(),
relative_path: filename.clone(),
};
self.iso_store
.register_external(id, filename, size, report, boot_entries, source);
self.iso_store.register_external(
id,
filename,
size,
report,
boot_entries,
source,
);
count += 1;
}
Ok(count)
+17 -80
View File
@@ -25,11 +25,11 @@
//! Samba), we return `SmbState::SmbdMissing` and the UI surfaces the
//! gap. No panics, no retries, no silent failure.
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use parking_lot::Mutex;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", tag = "state")]
@@ -72,9 +72,7 @@ impl SmbManager {
/// ISO under `smb_dir/<slug>/` becomes a share named `<slug>`. Returns
/// the sorted list.
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
.flatten()
.filter(|e| e.path().is_dir())
@@ -132,9 +130,7 @@ impl SmbManager {
let shares = match self.write_conf() {
Ok(v) => v,
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();
return s;
}
@@ -143,8 +139,7 @@ impl SmbManager {
.args([
"--foreground",
"--no-process-group",
"--configfile",
self.conf_path.to_str().unwrap_or(""),
"--configfile", self.conf_path.to_str().unwrap_or(""),
"--log-stdout",
])
.stdin(Stdio::null())
@@ -161,9 +156,7 @@ impl SmbManager {
s
}
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();
s
}
@@ -175,15 +168,11 @@ impl SmbManager {
#[allow(unsafe_code)]
pub fn reconcile(&self) -> SmbState {
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() {
Ok(v) => v,
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();
return s;
}
@@ -202,13 +191,8 @@ impl SmbManager {
// covers this is `nix`, which pulls ~40 transitive deps for a
// single signal send. One documented unsafe call is the better
// tradeoff for a container-first project.
unsafe {
libc::kill(pid, libc::SIGHUP);
}
let s = SmbState::Running {
pid: pid as u32,
shares,
};
unsafe { libc::kill(pid, libc::SIGHUP); }
let s = SmbState::Running { pid: pid as u32, shares };
*self.state.lock() = s.clone();
s
} else {
@@ -228,19 +212,15 @@ impl SmbManager {
}
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) {
if dir.join("smbd").is_file() {
return true;
}
if dir.join("smbd").is_file() { return true; }
}
false
}
const SMB_CONF_GLOBAL: &str = r"[global]
workgroup = OPENPXE
workgroup = PXEFORGE
server min protocol = SMB2
smb ports = 445
log level = 1
@@ -255,15 +235,6 @@ lock directory = /tmp
state directory = /tmp
cache 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
@@ -274,11 +245,7 @@ deadtime = 1
/// Idempotent: if the target dir already contains `sources/boot.wim`, we
/// skip extraction. Callers who want a forced re-extract should remove the
/// 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);
if target.join("sources").join("boot.wim").is_file() {
tracing::debug!(target: "openpxe::smb", slug, "ISO already extracted, skipping");
@@ -296,9 +263,7 @@ pub fn extract_windows_iso(
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output()?;
if out.status.success() {
return Ok(target);
}
if out.status.success() { return Ok(target); }
tracing::warn!(
target: "openpxe::smb",
stderr=%String::from_utf8_lossy(&out.stderr),
@@ -313,9 +278,7 @@ pub fn extract_windows_iso(
.args(["-C"])
.arg(&target)
.output()?;
if out.status.success() {
return Ok(target);
}
if out.status.success() { return Ok(target); }
return Err(std::io::Error::other(format!(
"bsdtar failed: {}",
String::from_utf8_lossy(&out.stderr)
@@ -331,9 +294,7 @@ fn which(cmd: &str) -> Option<PathBuf> {
let paths = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&paths) {
let p = dir.join(cmd);
if p.is_file() {
return Some(p);
}
if p.is_file() { return Some(p); }
}
None
}
@@ -359,9 +320,7 @@ mod tests {
let m = SmbManager::new(dir.path().into());
let st = m.start();
// 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);
}
@@ -388,27 +347,5 @@ mod tests {
assert!(conf.contains("guest ok = yes"));
assert!(conf.contains("read only = yes"));
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}"
);
}
}
}
+11 -197
View File
@@ -3,8 +3,8 @@
use crate::entry::{BootEntry, BootKind, KernelArgs};
use crate::introspect::{introspect, DistroFamily, IntrospectionReport};
use bytes::Bytes;
use openpxe_core::{Error, Result};
use parking_lot::RwLock;
use openpxe_core::{Error, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
@@ -48,25 +48,6 @@ pub struct IsoMeta {
/// Old `meta.json` files without this field deserialize as `Local`.
#[serde(default)]
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 {
@@ -113,7 +94,6 @@ impl UploadHandle {
introspection,
boot_entries,
source: IsoSource::Local,
password_hash: None,
};
store.persist_meta(&meta).await?;
store.insert(meta.clone());
@@ -170,9 +150,7 @@ impl IsoStore {
let mut entries = tokio::fs::read_dir(self.iso_dir.as_path()).await?;
while let Some(e) = entries.next_entry().await? {
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
.file_name()
.and_then(|s| s.to_str())
@@ -278,10 +256,7 @@ impl IsoStore {
/// share or unmount the NFS share entirely.
pub async fn delete(&self, id: &str) -> Result<()> {
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 {
let iso = self.iso_path(id);
let meta_path = self.meta_path(id);
@@ -314,7 +289,6 @@ impl IsoStore {
introspection,
boot_entries,
source,
password_hash: None,
};
self.inner.write().isos.insert(id, meta);
}
@@ -324,69 +298,9 @@ impl IsoStore {
/// to clean out stale entries.
pub fn drop_external_source(&self, mount_id: &str) {
let mut g = self.inner.write();
g.isos.retain(
|_, 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()))
g.isos.retain(|_, m| {
!matches!(&m.source, IsoSource::Nfs { mount_id: mid, .. } if mid == mount_id)
});
}
}
@@ -432,10 +346,7 @@ pub fn generate_boot_entries_for(
/// Build `BootEntry`s from the introspection report. URLs are relative —
/// the HTTP layer rewrites them with the public base URL per request.
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 {
DistroFamily::WindowsPe if r.has_boot_wim => {
// Standard wimboot chain. Paths are in-ISO; the HTTP layer maps
@@ -459,22 +370,12 @@ fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> V
fam if r.kernel_path.is_some() => {
let base = format!("iso/{id}");
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 args = KernelArgs {
cmdline: linux_cmdline(fam, id),
};
let initrd_urls = r.initrd_paths.iter().map(|p| format!("{base}{p}")).collect();
let args = KernelArgs { cmdline: linux_cmdline(fam, id) };
vec![BootEntry {
id: format!("{id}-linux"),
title,
kind: BootKind::LinuxKernel {
kernel_url,
initrd_urls,
args,
},
kind: BootKind::LinuxKernel { kernel_url, initrd_urls, args },
}]
}
_ => {
@@ -483,9 +384,7 @@ fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> V
vec![BootEntry {
id: format!("{id}-sanboot"),
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") },
}]
}
}
@@ -517,8 +416,6 @@ fn linux_cmdline(family: DistroFamily, id: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::introspect::{DistroFamily, IntrospectionReport};
use tempfile::tempdir;
#[test]
fn slugify_basic() {
@@ -530,87 +427,4 @@ mod tests {
// If a path sneaks in, file_stem strips the directory — OK, not a hazard.
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());
}
}
+13 -50
View File
@@ -54,10 +54,7 @@ pub struct WimPatcher {
impl WimPatcher {
#[must_use]
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
@@ -75,40 +72,31 @@ impl WimPatcher {
let work = match tempfile::tempdir() {
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/.
let staging = work.path().join("stage/Windows/System32");
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) {
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);
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:
// add <stage>/Windows/System32 /Windows/System32
let update_file = work.path().join("update.cmd");
let update_cmd = format!("add \"{}\" \"/Windows/System32\"\n", staging.display());
let update_cmd = format!(
"add \"{}\" \"/Windows/System32\"\n",
staging.display()
);
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).
@@ -132,9 +120,7 @@ impl WimPatcher {
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}") },
}
}
}
@@ -147,9 +133,7 @@ fn which(cmd: &str) -> Option<PathBuf> {
let paths = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&paths) {
let p = dir.join(cmd);
if p.is_file() {
return Some(p);
}
if p.is_file() { return Some(p); }
}
None
}
@@ -190,11 +174,7 @@ fn render_startnet(host: &str, share: &str) -> String {
)
.unwrap();
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!(
s,
":mapshare\r\nnet use Z: \\\\{host}\\{share} /user:guest \"\" /persistent:no && goto mapped\r\n\
@@ -225,23 +205,6 @@ mod tests {
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]
fn patcher_reports_wimlib_missing_gracefully() {
// We don't assume wimlib is present in CI; this checks the missing
+19 -61
View File
@@ -4,16 +4,16 @@
use clap::{Parser, Subcommand};
use openpxe_core::{
ClientRegistry, Config, DeploymentQueue, DhcpMode, HostBindings, LogBus, LogBusLayer, Metrics,
ClientRegistry, Config, DhcpMode, DeploymentQueue, HostBindings, LogBus, LogBusLayer, Metrics,
SettingsStore,
};
use openpxe_dhcp_proxy::DhcpProxyServer;
use openpxe_http_api::{build_router, AppState};
use openpxe_iso_store::{IsoStore, NfsManager, SmbManager};
use std::sync::Arc;
use openpxe_tftp::TftpServer;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::io::AsyncReadExt;
#[derive(Debug, Parser)]
@@ -39,7 +39,7 @@ enum Command {
/// docker run --rm \
/// -v /my/isos:/seed:ro \
/// -v openpxe-data:/var/lib/openpxe/isos \
/// openpxe:0.3.2 seed --from /seed
/// openpxe:0.1.0 seed --from /seed
Seed {
/// Source directory containing one or more `.iso` files.
#[arg(long)]
@@ -100,10 +100,9 @@ async fn main() -> anyhow::Result<()> {
let iso_store = IsoStore::new(config.paths.iso_dir.clone());
iso_store.load_from_disk().await?;
let clients = ClientRegistry::new();
let queue = DeploymentQueue::new();
let gates = DeploymentQueue::new();
let settings = SettingsStore::load_or_default(&config.paths.work_dir);
let hosts = HostBindings::load_or_default(&config.paths.work_dir);
let boot_log = openpxe_core::BootLog::load_or_default(&config.paths.work_dir);
let metrics = Metrics::new();
// Build the SMB manager unconditionally — it starts/stops on the
@@ -139,9 +138,8 @@ async fn main() -> anyhow::Result<()> {
iso_store: iso_store.clone(),
clients: clients.clone(),
settings: settings.clone(),
queue: queue.clone(),
queue: gates.clone(),
hosts: hosts.clone(),
boot_log: boot_log.clone(),
metrics: metrics.clone(),
smb: Some(smb.clone()),
nfs: nfs.clone(),
@@ -158,15 +156,7 @@ async fn main() -> anyhow::Result<()> {
let http_task = tokio::spawn(async move {
let listener = tokio::net::TcpListener::bind(http_addr).await?;
tracing::info!(target: "openpxe::http", "HTTP listening on {http_addr}");
// `into_make_service_with_connect_info` is required so per-request
// `ConnectInfo<SocketAddr>` extractors can resolve the peer IP —
// used by `/boot/<entry>.ipxe` to record the booting client's
// address into the Host log. Without this the extractor 500s.
axum::serve(
listener,
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await?;
axum::serve(listener, router).await?;
Ok::<_, anyhow::Error>(())
});
@@ -220,11 +210,7 @@ async fn run_command(cmd: Command, config: Config) -> anyhow::Result<()> {
/// Reuses `IsoStore::begin_upload` / `finish` so the resulting meta on disk
/// is identical to a web upload — same slug rules, same introspection, same
/// 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());
store.load_from_disk().await?;
let mut entries = tokio::fs::read_dir(src).await?;
@@ -232,12 +218,7 @@ async fn seed_from_dir(
let mut skipped = 0u32;
while let Some(entry) = entries.next_entry().await? {
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;
}
let filename = p
@@ -245,14 +226,8 @@ async fn seed_from_dir(
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("non-utf8 filename: {}", p.display()))?
.to_string();
println!(
" {} ({} bytes)",
filename,
tokio::fs::metadata(&p).await?.len()
);
if dry_run {
continue;
}
println!(" {} ({} bytes)", filename, tokio::fs::metadata(&p).await?.len());
if dry_run { continue; }
let mut handle = match store.begin_upload(&filename).await {
Ok(h) => h,
@@ -267,23 +242,15 @@ async fn seed_from_dir(
let mut buf = vec![0u8; 1024 * 1024];
loop {
let n = file.read(&mut buf).await?;
if n == 0 {
break;
}
if n == 0 { break; }
let chunk: bytes::Bytes = buf[..n].to_vec().into();
handle.write_chunk(&chunk).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;
}
println!(
"\nimported={imported} skipped={skipped} {}",
if dry_run { "(dry run)" } else { "" }
);
println!("\nimported={imported} skipped={skipped} {}", if dry_run { "(dry run)" } else { "" });
Ok(())
}
@@ -327,8 +294,9 @@ fn hostname() -> std::io::Result<String> {
if let Ok(h) = std::fs::read_to_string("/proc/sys/kernel/hostname") {
return Ok(h.trim().to_string());
}
std::env::var("HOSTNAME")
.map_err(|_| std::io::Error::new(std::io::ErrorKind::NotFound, "no hostname"))
std::env::var("HOSTNAME").map_err(|_| std::io::Error::new(
std::io::ErrorKind::NotFound, "no hostname",
))
}
fn init_tracing(bus: Arc<LogBus>) {
@@ -360,10 +328,7 @@ fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo {
// `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.
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) {
for line in text.lines() {
if !line.contains(&our_ip.to_string()) {
@@ -388,10 +353,7 @@ fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo {
}
// `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 Some(line) = text.lines().next() {
let mut parts = line.split_whitespace();
@@ -412,11 +374,7 @@ fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo {
fn prefix_to_dotted(prefix: u8) -> String {
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!(
"{}.{}.{}.{}",
(mask >> 24) & 0xff,
+14 -48
View File
@@ -45,12 +45,7 @@ impl TftpServer {
clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
) -> Self {
Self {
bind,
port,
clients,
metrics,
}
Self { bind, port, clients, metrics }
}
pub async fn run(self) -> anyhow::Result<()> {
@@ -91,9 +86,7 @@ async fn handle_rrq(
let Some(req) = parse_rrq(&packet) else {
return Ok(());
};
let Request {
filename, options, ..
} = req;
let Request { filename, options, .. } = req;
// Per-transfer ephemeral socket.
let sock = bind_udp(bind_ip, 0)?;
@@ -105,9 +98,7 @@ async fn handle_rrq(
&peer.ip().to_string(),
Some(peer.ip()),
None,
ClientEvent::TftpRead {
file: filename.clone(),
},
ClientEvent::TftpRead { file: filename.clone() },
);
return Ok(());
};
@@ -121,9 +112,7 @@ async fn handle_rrq(
&peer.ip().to_string(),
Some(peer.ip()),
None,
ClientEvent::TftpRead {
file: filename.clone(),
},
ClientEvent::TftpRead { file: filename.clone() },
);
// Negotiate options.
@@ -184,9 +173,7 @@ async fn handle_rrq(
// Send one window worth of DATA.
for _ in 0..window {
if offset >= total {
break;
}
if offset >= total { break; }
let end = (offset + blksize).min(total);
let chunk = &file_bytes[offset..end];
let pkt = encode_data(block_no, chunk);
@@ -268,30 +255,20 @@ struct 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]]);
if op != OP_RRQ {
return None;
}
if op != OP_RRQ { return None; }
let mut rest = &pkt[2..];
let filename = read_cstr(&mut rest)?;
let mode = read_cstr(&mut rest)?;
let mut options = Vec::new();
while !rest.is_empty() {
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();
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> {
@@ -339,12 +316,8 @@ async fn recv_ack(sock: &UdpSocket, peer: SocketAddr) -> anyhow::Result<u16> {
let mut buf = [0u8; 32];
loop {
let (n, from) = sock.recv_from(&mut buf).await?;
if from.ip() != peer.ip() {
continue;
}
if n < 4 {
continue;
}
if from.ip() != peer.ip() { continue; }
if n < 4 { continue; }
let op = u16::from_be_bytes([buf[0], buf[1]]);
match op {
OP_ACK => return Ok(u16::from_be_bytes([buf[2], buf[3]])),
@@ -371,19 +344,14 @@ async fn wait_for_ack(
Ok(Ok(_)) => {}
Ok(Err(_)) | Err(_) => {
tries += 1;
if tries > 5 {
return Ok(false);
}
if tries > 5 { return Ok(false); }
}
}
}
}
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))?;
sock.set_reuse_address(true)?;
sock.set_nonblocking(true)?;
@@ -414,9 +382,7 @@ pub fn plan_window(
let mut o = offset;
let mut b = starting_block;
for _ in 0..window {
if o >= total {
break;
}
if o >= total { break; }
let end = (o + blksize).min(total);
out.push((b, end - o));
o = end;
+26 -48
View File
@@ -8,26 +8,23 @@
* CSS lands). */
:root {
/* Jet-black dark palette (default). Modelled on Netbox Labs's
near-black product chrome — surfaces step from #000 → #0d → #16 → #1c
rather than the previous blue-tinted ramp, so the UI reads as a
genuine "dark" rather than "dim navy". */
--bg: #000000;
--bg-panel: #0a0a0a;
--bg-panel-2: #141414;
--bg-elev: #1c1c1c;
--fg: #e8eaed;
--fg-dim: #9aa0a6;
--fg-dimmer: #6b7077;
--accent: #00d4b4; /* Netbox-ish teal — kept for brand */
/* Dark palette (default). */
--bg: #0b1018;
--bg-panel: #121826;
--bg-panel-2: #1a2334;
--bg-elev: #223047;
--fg: #e4e8ef;
--fg-dim: #8a94a7;
--fg-dimmer: #5a6379;
--accent: #00d4b4; /* Netbox-ish teal */
--accent-dim: #07a38c;
--warn: #ffb347;
--err: #ef6e6e;
--ok: #4ade80;
--border: #1f1f1f;
--border-soft: #141414;
--terminal-bg: #000000;
--shadow-card: 0 1px 0 rgba(255,255,255,0.02), 0 8px 24px rgba(0,0,0,0.55);
--border: #223047;
--border-soft: #172033;
--terminal-bg: #06090e;
--shadow-card: 0 1px 0 rgba(255,255,255,0.02), 0 8px 24px rgba(0,0,0,0.25);
--radius: 6px;
--radius-lg: 10px;
--sidebar-w: 240px;
@@ -116,30 +113,10 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
}
.sidebar nav a.active .count { background: var(--accent); color: #002923; }
.sidebar .footer {
padding: 12px 18px; border-top: 1px solid var(--border);
padding: 10px 18px; border-top: 1px solid var(--border);
color: var(--fg-dimmer); font-size: 11px;
display: flex; flex-direction: column; gap: 4px;
}
.sidebar .footer code { background: transparent; color: var(--fg-dim); padding: 0;
font-size: 11px; word-break: break-all; }
.sidebar .footer .status-row {
display: flex; align-items: center; gap: 8px;
margin-bottom: 4px;
}
.sidebar .footer .status-row .dot {
width: 8px; height: 8px; border-radius: 50%; display: inline-block;
background: var(--fg-dimmer); flex: none;
}
.sidebar .footer .status-row .dot.ok { background: var(--ok);
box-shadow: 0 0 6px color-mix(in srgb, var(--ok) 60%, transparent); }
.sidebar .footer .status-row .dot.err { background: var(--err); }
.sidebar .footer .status-row .dot.warn { background: var(--warn); }
.sidebar .footer .status-label { color: var(--fg-dim); }
.sidebar .footer .status-value { color: var(--fg); font-weight: 600; }
.sidebar .footer .status-value.ok { color: var(--ok); }
.sidebar .footer .status-value.err { color: var(--err); }
.sidebar .footer .status-value.warn { color: var(--warn); }
.sidebar .footer .footer-sub { color: var(--fg-dimmer); margin-top: 2px; }
.sidebar .footer code { background: transparent; color: var(--fg-dim); padding: 0; }
/* ── Top bar ───────────────────────────────────────────────────────── */
@@ -304,7 +281,8 @@ label.check input { accent-color: var(--accent); }
/* ── Imaging progress widget ───────────────────────────────────────
Animated brand mark paired with a horizontal progress bar; surfaces
on Dashboard and the Queue tab. */
on Dashboard and the Queue tab. Renamed from `.forge-progress` in
v0.3.0 — the anvil-themed naming is gone with the rebrand. */
.queue-progress {
display: flex; align-items: center; gap: 16px;
padding: 16px;
@@ -402,7 +380,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; }
@media (max-width: 900px) { .form-row { grid-template-columns: 1fr; } }
/* ── Queued deployment visual ────────────────────────────────────── */
/* ── Gate queue "horse race" visual ──────────────────────────────── */
.queue-track {
display: grid; gap: 6px;
padding: 10px 0;
@@ -454,29 +432,29 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
.terminal .input-row {
display: flex; align-items: center; gap: 8px;
padding: 8px 14px;
background: #050505;
border-top: 1px solid #181818;
background: #0a0e15;
border-top: 1px solid #1d2330;
}
.terminal .input-row .prompt { color: var(--accent); font-family: var(--mono); }
.terminal .input-row input {
flex: 1; background: transparent; border: 0; color: var(--fg);
flex: 1; background: transparent; border: 0; color: #e4e8ef;
font: inherit; font-family: var(--mono); font-size: 13px;
outline: none; padding: 4px 0;
}
.terminal .toolbar {
display: flex; gap: 8px; align-items: center;
padding: 8px 14px;
background: #050505;
border-bottom: 1px solid #181818;
font-size: 12px; color: var(--fg-dim);
background: #0a0e15;
border-bottom: 1px solid #1d2330;
font-size: 12px; color: #8a94a7;
}
.terminal .toolbar .right { margin-left: auto; display: flex; gap: 6px; }
.terminal .toolbar button {
padding: 3px 9px; font-size: 11px;
background: transparent; color: var(--fg-dim); border: 1px solid #181818;
background: transparent; color: #8a94a7; border: 1px solid #1d2330;
font-weight: 500;
}
.terminal .toolbar button:hover { color: var(--fg); background: #181818; }
.terminal .toolbar button:hover { color: #e4e8ef; background: #1d2330; }
/* ── About card ─────────────────────────────────────────────────── */
.about-hero { padding: 20px 24px; }
+35 -215
View File
@@ -246,7 +246,7 @@
return el('div', {class:'grid'}, [networkCard]);
},
queue: async () => {
gate: async () => {
const [{ entries = [] }, isos] = await Promise.all([
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; }
msg.textContent = 'Launched ' + j.assigned + ' client' + (j.assigned===1?'':'s') + ' → ' + j.target;
msg.className = 'msg ok';
render('queue');
render('gate');
};
const track = entries.length
@@ -284,22 +284,22 @@
: el('span', {class:'tag accent'}, 'waiting')),
el('button', {class:'ghost', onclick: async () => {
await fetch('/api/queue/' + encodeURIComponent(g.id), {method:'DELETE'});
render('queue');
render('gate');
}}, 'Release'),
]))
)
: el('div', {class:'empty'},
'No clients queued. Boot a client and choose "Queued Deployment" in the PXE menu.');
'No clients at the gate. Boot a client and choose "Queued Deployment" in the PXE menu.');
const imaging = entries.filter(g => g.assigned_target).length;
return el('div', {class:'grid'}, [
el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Status')),
el('header', {}, el('h2', {}, 'Forge')),
queueProgressWidget(imaging, entries.length),
]),
el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Launch image for queued clients')),
el('header', {}, el('h2', {}, 'Launch an image across the gate')),
el('div', {class:'body'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Target image'),
@@ -312,7 +312,7 @@
]),
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Queue positions'),
el('h2', {}, 'Gate positions'),
el('span', {class:'sub'}, entries.length + ' waiting'),
]),
el('div', {class:'body'}, track),
@@ -346,164 +346,38 @@
});
file.onchange = () => { if (file.files[0]) upload(file.files[0]); };
// Upload telemetry. We surface bytes-sent + percent + ETA so when
// an upload stalls (e.g. a reverse proxy is buffering or rejecting
// a >100MB body) the operator can see it instead of staring at a
// 0% bar. We also tag the most common failure modes — timeout,
// network drop, HTTP 413/502/504 — with hints so the path forward
// is obvious from the UI.
function upload(f) {
const started = Date.now();
const bar = $('#bar');
const setStatus = (text, cls) => { upMsg.textContent = text; upMsg.className = 'msg ' + (cls || ''); };
setStatus('Uploading ' + f.name + ' (' + fmtBytes(f.size) + ')…');
upMsg.textContent = 'Uploading ' + f.name + ' (' + fmtBytes(f.size) + ')…';
upMsg.className = 'msg';
prog.classList.add('active');
bar.style.width = '0%';
const fd = new FormData(); fd.append('file', f);
const xhr = new XMLHttpRequest();
// 4-hour ceiling for very large ISOs over slow links. Browser
// default is 0 (never time out); we set an explicit cap so a
// stalled connection doesn't masquerade as "still uploading".
xhr.timeout = 4 * 60 * 60 * 1000;
xhr.upload.onprogress = e => {
if (!e.lengthComputable) return;
const pct = (e.loaded / e.total) * 100;
bar.style.width = pct.toFixed(1) + '%';
const elapsed = (Date.now() - started) / 1000;
const rate = elapsed > 0 ? e.loaded / elapsed : 0;
const remain = rate > 0 ? (e.total - e.loaded) / rate : 0;
setStatus(
'Uploading ' + f.name + ' — ' +
fmtBytes(e.loaded) + ' of ' + fmtBytes(e.total) +
' (' + pct.toFixed(1) + '%, ' + fmtBytes(rate) + '/s' +
(remain > 0 ? ', ' + Math.ceil(remain) + 's left' : '') + ')');
if (e.lengthComputable) $('#bar').style.width = (e.loaded/e.total*100).toFixed(1) + '%';
};
xhr.onload = () => {
prog.classList.remove('active');
bar.style.width = '0';
$('#bar').style.width = '0';
if (xhr.status >= 200 && xhr.status < 300) {
setStatus('Uploaded & analyzed: ' + f.name + ' (' + fmtBytes(f.size) + ')', 'ok');
upMsg.textContent = 'Uploaded & analyzed.'; upMsg.className = 'msg ok';
render('storage');
return;
} else {
upMsg.textContent = 'Upload failed: ' + xhr.status + ' ' + xhr.responseText;
upMsg.className = 'msg err';
}
let hint = '';
if (xhr.status === 413) hint = ' — body too large. A reverse proxy in front of OpenPXE (Cloudflare free tier caps at 100 MB) likely rejected it. Try the LAN IP directly.';
else if (xhr.status === 502) hint = ' — bad gateway. Reverse proxy lost the upstream mid-stream.';
else if (xhr.status === 504) hint = ' — gateway timeout. The upload took longer than the proxy allows; try the LAN IP.';
else if (xhr.status === 409) hint = ' — an ISO with this name already exists. Remove the old one or rename.';
setStatus('Upload failed: HTTP ' + xhr.status + ' ' + (xhr.responseText || '').slice(0, 200) + hint, 'err');
};
xhr.onerror = () => {
prog.classList.remove('active');
setStatus('Upload failed: network error or connection closed mid-stream. ' +
'If you went through a reverse proxy, try the server\'s LAN IP directly.', 'err');
};
xhr.ontimeout = () => {
prog.classList.remove('active');
setStatus('Upload timed out after 4 hours.', 'err');
};
xhr.onabort = () => {
prog.classList.remove('active');
setStatus('Upload aborted.', 'err');
};
xhr.onerror = () => { upMsg.textContent = 'Network error.'; upMsg.className = 'msg err'; };
xhr.open('POST', '/api/isos');
xhr.send(fd);
}
// ── ISO table (mixed local + NFS) ──
// 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 rows = isos.map(i => {
const b = bootability(i, settings);
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'}, [
el('td', {}, [
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),
]),
el('div', {}, i.filename),
!b.ok ? el('div', {class:'row-warn'}, '⚠ ' + b.reason)
: (b.warn ? el('div', {class:'row-warn'}, '⚠ ' + b.warn) : null),
]),
@@ -512,35 +386,26 @@
el('td', {},
el('span', {class:'src-badge' + (isNfs ? ' nfs' : '')},
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', {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'),
el('td', {style:'text-align:right'},
isNfs
? el('span', {class:'tag', style:'opacity:.6'}, 'on NFS')
? el('span', {class:'tag', style:'opacity:.6'}, 'manage on NFS share')
: el('button', {class:'danger', onclick: async () => {
if (!confirm('Remove this image?')) return;
await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'});
render('storage');
}}, 'Remove'),
]),
}}, 'Remove')),
]);
rowsAndEditors.push(tr, editorRow);
return tr;
});
const isoTable = isos.length
? el('table', {}, [
el('thead', {}, el('tr', {}, [
el('th',{},'Name'), el('th',{},'Type'),
el('th',{class:'num'},'Size'),
el('th',{},'Source'), el('th',{},'Auth'),
el('th',{},'Uploaded'), el('th',{},''),
el('th',{},'Source'), el('th',{},'Uploaded'), el('th',{},''),
])),
el('tbody', {}, rowsAndEditors),
el('tbody', {}, rows),
])
: el('div', {class:'empty'}, 'No images yet. Upload an ISO or mount an NFS share.');
@@ -641,11 +506,9 @@
},
hosts: async () => {
const [{ hosts = [] }, isos, bootLogRes] = await Promise.all([
const [{ hosts = [] }, isos] = await Promise.all([
getJSON('/api/hosts'), getJSON('/api/isos'),
getJSON('/api/boot-log').catch(() => ({ events: [] })),
]);
const bootEvents = bootLogRes.events || [];
const targets = isos.flatMap(i => i.boot_entries.map(e => ({
id: e.id, title: e.title + ' — ' + familyLabel(i.introspection.family),
})));
@@ -721,7 +584,8 @@
upsertBtn, msg,
el('p', {class:'msg', style:'margin-top:14px'},
'When a client with a bound MAC requests boot.ipxe, OpenPXE ' +
'short-circuits past the interactive menu and chains directly.'),
'short-circuits past the interactive menu and chains directly. ' +
'Inspired by Tinkerbell smee\'s MAC-prepended URL pattern.'),
]),
]),
el('div', {class:'card'}, [
@@ -731,37 +595,6 @@
]),
table,
]),
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Host log'),
el('span', {class:'sub'},
bootEvents.length + ' event' + (bootEvents.length === 1 ? '' : 's')),
]),
bootEvents.length
? el('table', {}, [
el('thead', {}, el('tr', {}, [
el('th', {}, 'Time'),
el('th', {}, 'MAC'),
el('th', {}, 'IP'),
el('th', {}, 'Image'),
])),
el('tbody', {},
bootEvents.map(e => el('tr', {}, [
el('td', {}, fmtAgo(e.timestamp)),
el('td', {class:'mono'}, e.mac || el('span', {class:'tag'}, '(unknown)')),
el('td', {class:'mono'}, e.ip ? String(e.ip) : '—'),
el('td', {}, [
el('span', {style:'font-weight:600'}, e.target_title || e.target_id),
el('div', {class:'meta',
style:'color:var(--fg-dim);font-size:11.5px;margin-top:2px'},
e.target_id),
]),
]))),
])
: el('div', {class:'empty'},
'No boot events yet. When a PXE client chains a boot entry, ' +
'it lands here with the MAC, IP, and image it received.'),
]),
]);
},
@@ -925,7 +758,7 @@
const viewTitles = {
dashboard: 'Dashboard',
network: 'Network',
queue: 'Queue',
gate: 'Queue',
storage: 'Storage',
hosts: 'Hosts',
terminal: 'Terminal',
@@ -986,24 +819,6 @@
}
}
// Set the sidebar footer "Service status:" line. The chip itself moved
// off the topbar in v0.4.0 — operators wanted readiness, advertised
// URL, and the boot IP grouped together as the bottom-left summary.
function setReady(state) {
const dot = $('[data-bind=ready_dot]');
const lbl = $('[data-bind=ready_label]');
if (!dot || !lbl) return;
const map = {
ready: { cls: 'ok', text: 'Ready' },
notready: { cls: 'err', text: 'Not ready' },
unreachable: { cls: 'err', text: 'Unreachable' },
};
const m = map[state] || { cls: 'warn', text: 'Checking…' };
dot.className = 'dot ' + m.cls;
lbl.className = 'status-value ' + m.cls;
lbl.textContent = m.text;
}
async function refreshChips() {
try {
const s = await getJSON('/api/status');
@@ -1013,9 +828,14 @@
$$('[data-bind=client_count],[data-bind=client_count2]').forEach(n => n.textContent = String(s.client_count));
$$('[data-bind=queue_count],[data-bind=queue_count2]').forEach(n => n.textContent = String(s.queue_count));
$$('[data-bind=host_count]').forEach(n => n.textContent = String(s.host_bindings || 0));
setReady(r.ok ? 'ready' : 'notready');
const chip = $('[data-bind=ready_chip]');
if (chip) {
if (r.ok) { chip.textContent = '● ready'; chip.className = 'chip ready'; }
else { chip.textContent = '● not ready'; chip.className = 'chip notready'; }
}
} catch {
setReady('unreachable');
const chip = $('[data-bind=ready_chip]');
if (chip) { chip.textContent = '● unreachable'; chip.className = 'chip notready'; }
}
}
+3 -7
View File
@@ -29,7 +29,7 @@
<img src="/assets/logo.svg" alt="" />
<div>
<strong>OpenPXE</strong>
<div class="sub">v<span data-bind="version">0.4.0</span></div>
<div class="sub">v<span data-bind="version">0.3.0</span></div>
</div>
</div>
<nav>
@@ -51,12 +51,7 @@
<a data-view="about">About</a>
</nav>
<div class="footer">
<div class="status-row">
<span class="dot" data-bind="ready_dot" title="Server readiness"></span>
<span class="status-label">Service status:</span>
<span class="status-value" data-bind="ready_label">checking…</span>
</div>
<div class="footer-sub">Advertised to clients</div>
Advertised to clients<br/>
<code>{{BASE_URL}}</code>
</div>
</aside>
@@ -64,6 +59,7 @@
<header class="topbar">
<h1 data-bind="view_title">Dashboard</h1>
<div class="spacer"></div>
<span class="chip" data-bind="ready_chip" title="Server readiness">checking…</span>
<span class="chip"><strong data-bind="iso_count2">0</strong>&nbsp;images</span>
<span class="chip"><strong data-bind="client_count2">0</strong>&nbsp;clients</span>
<span class="chip"><strong data-bind="queue_count2">0</strong>&nbsp;in queue</span>
+4 -12
View File
@@ -15,27 +15,19 @@ pub fn index_html(base_url: &str) -> String {
}
#[must_use]
pub fn app_js() -> &'static str {
APP_JS
}
pub fn app_js() -> &'static str { APP_JS }
#[must_use]
pub fn app_css() -> &'static str {
APP_CSS
}
pub fn app_css() -> &'static str { APP_CSS }
#[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
/// transition and the imaging-progress widget on Dashboard / Queue.
/// Pure SVG + SMIL, no JS, no GIF.
#[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 APP_CSS: &str = include_str!("app.css");
+1 -1
View File
@@ -34,7 +34,7 @@ spec:
fsGroup: 10001
containers:
- name: openpxe
image: gitea.milesward.dev/mward4/openpxe:0.3.2
image: ghcr.io/casperadmin/openpxe:0.1.0
imagePullPolicy: IfNotPresent
ports:
- 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
Recommended once you've done it once. Image is published to
`gitea.milesward.dev/mward4/openpxe:0.3.2` (or your equivalent) and
`gitea.milesward.dev/mward4/openpxe:0.1.0` (or your equivalent) and
every Unraid template / docker-compose just references the tag.
Pre-flight:
@@ -40,14 +40,14 @@ What it does:
3. `docker build` against `deploy/docker/Dockerfile`.
4. `docker login gitea.milesward.dev:3000` using a temp `DOCKER_CONFIG`
so the credential never lands in your real `~/.docker/config.json`.
5. `docker push` both `:0.3.2` and `:latest`.
5. `docker push` both `:0.1.0` and `:latest`.
6. Logout, scrub the temp config, delete the workspace.
After it finishes, in Unraid → Docker → Add Container, set:
| Field | Value |
|------------|-------------------------------------------------|
| Repository | `gitea.milesward.dev/mward4/openpxe:0.3.2` |
| Repository | `gitea.milesward.dev/mward4/openpxe:0.1.0` |
| Network | `host` |
| Extra args | `--cap-add=NET_BIND_SERVICE` |
@@ -88,14 +88,14 @@ then:
```bash
# On the build host
docker save openpxe:0.3.2 | gzip > openpxe-0.3.2.tar.gz
docker save openpxe:0.1.0 | gzip > openpxe-0.1.0.tar.gz
# Transfer (rsync / scp / SMB / ZFS-replicate / sneakernet)
scp openpxe-0.3.2.tar.gz root@unraid:/tmp/
scp openpxe-0.1.0.tar.gz root@unraid:/tmp/
# On Unraid
gunzip -c /tmp/openpxe-0.3.2.tar.gz | docker load
docker tag openpxe:0.3.2 gitea.milesward.dev/mward4/openpxe:0.3.2
gunzip -c /tmp/openpxe-0.1.0.tar.gz | docker load
docker tag openpxe:0.1.0 gitea.milesward.dev/mward4/openpxe:0.1.0
```
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
implementation — DHCP proxy + TFTP + iPXE chainload + HTTP ISO
streaming, all in one process. Web UI for ISO upload, NFS share
mounting, and Queued Deployment for coordinated launch of one ISO
across many waiting clients.
mounting, and Queued Deployment ("horse-race" simultaneous launch
of one ISO across many waiting clients).
NEVER touches the client OS trust store: no test-signed drivers,
no testsigning toggle, no httpdisk.sys. Windows boot uses vanilla
+2 -2
View File
@@ -1,7 +1,7 @@
# Phase 6 — recommendations
The v0.3.2 cut leaves OpenPXE in a state where the entire protocol stack
and operator UI are exercised by the automated test suite, the container is
The v0.2.0 cut leaves OpenPXE in a state where the entire protocol stack
and operator UI are exercised by 66 automated tests, the container is
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
validation** plus a small batch of features that can only sensibly be
+8 -8
View File
@@ -288,17 +288,18 @@ warning-free. Replaced `format!()`-into-`String` with
`Reverse`, fixed `map_or(false, …)` → `is_some_and`, and a handful of
other idiom fixes.
**UI overhaul** for the pre-beta milestone:
**UI overhaul** for the v0.2.0 pre-beta milestone:
- Light + dark themes via `:root[data-theme=light]` token swap.
Toggled by a top-right button or the `T` key. Persisted in
localStorage; pre-paint inline script avoids dark→light flash.
- New SVG logos: a refined OpenPXE mark (`logo.svg`) and a compact
SMIL-animated loader (`loader.svg`). Pure SVG, embedded in the binary.
- Deployment progress widget on the Dashboard and Queue: animated
OpenPXE mark paired with a `linear-gradient(warn → accent)` progress bar
- New SVG logos: a refined anvil (`logo.svg`) and a SMIL-animated
`anvil-forge.svg` (rising sparks + pulsing underglow). Pure SVG —
no GIFs, no CSS keyframes for the sparks.
- "Forge progress" widget on the Dashboard and Queue: animated
anvil paired with a `linear-gradient(warn → accent)` progress bar
with a moving sheen. Goes idle (greyscale, no sheen) at zero
imaging load.
- Loader replaced "Loading..." text with the same OpenPXE mark.
- Loader replaced "Loading" text with the same anvil.
- Sidebar gains a **Hosts** tab.
**Windows boot validation**:
@@ -316,8 +317,7 @@ other idiom fixes.
fixes: explicit `net start Workstation` before `net use`, surfaces
errors instead of blind retries.
**Test posture**: protocol, HTTP, ISO-store, Windows script, queue, metrics,
and UI-offline checks all run in the workspace test suite.
**Test count**: 66 → up from 56 in v0.1.0.
## 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
throttled by your browser ↔ host link. The UI shows a progress bar; the
animated OpenPXE mark on the Dashboard tab fires up while imaging is in
animated anvil on the Dashboard tab fires up while imaging is in
flight.
### 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
short whitelisted command palette (`status`, `clients`, `queue`,
short whitelisted command palette (`status`, `clients`, `gate`,
`hosts`, `log`).
### 5d. Internet-side ISO sources
@@ -347,19 +347,19 @@ default for production hardware.
## 7. Re-imaging — the “Queued Deployment” flow
Different scenario: you have **a rack of 30 servers** to image
identically, all at once. Dont bind 30 MACs by hand. Use the queue.
identically, all at once. Dont bind 30 MACs by hand. Use the gate.
1. **Dont** create host bindings.
2. PXE-boot every machine. They land on the menu.
3. On each: select **Queued Deployment**. They get position #1, #2,
…, #30 and start long-polling.
4. In the UI: **Queue** tab shows all 30 lined up. Pick the
4. In the UI: **Forge Gate** tab shows all 30 lined up. Pick the
ISO, click **Assign to all waiting**.
5. Every clients open long-poll wakes up at the same instant and
chains the same boot script. They all start imaging
simultaneously.
simultaneously — the “horse race gate” opens.
The animated OpenPXE progress widget on the Dashboard runs while any client is
The animated anvil widget on the Dashboard runs while any client is
still in the kernel-fetch phase.
---