Compare commits

...
4 Commits
Author SHA1 Message Date
Miles WardandClaude Opus 4.8 9fc9a9a1af v0.5.8: Windows ISOs just work (HTTP sanboot) + Storage UX
Windows boot, the "less is more" way. Windows ISOs now boot via iPXE
HTTP sanboot of the raw image — iPXE exposes the unmodified ISO as an
emulated CD backed by on-demand HTTP range reads, and Windows Setup
boots from it. This replaces the wimboot+SMB chain, which needed an SMB
server the host often can't provide (:445 collisions), served in-ISO
files via an ISO9660 lookup that failed on UDF-only Win11 ISOs, and was
gated behind a Settings toggle the WebUI never even exposed (so Windows
never booted). Now it needs only the HTTP port — works in any
environment, SMB or not — and nothing is injected into Windows (no
httpdisk.sys, no test certs, no trust-store changes; fully within the
project's hard rules).

- iso-store/store.rs: WindowsPe boot entry -> BootKind::SanBootIso of the
  raw iso/<id>.iso (render_entry already emits `sanboot --no-describe`).
- iso-store/introspect.rs: broaden Windows detection for UDF-only Win10/11
  ISOs — UTF-16LE markers (boot.wim/bootmgr/install.wim/microsoft),
  extra ASCII markers, and a filename heuristic, since their volume
  labels are cryptic and filenames are UTF-16. + unit tests.
- http-api/ipxe_script.rs: Windows installers submenu shows whenever a
  Windows ISO is present — no toggle, no "disabled in Settings".
- webui: dashboard no longer flags Windows ISOs (they boot now); the
  generic large-ISO warning reworded to read sensibly for genuinely
  non-bootable images (e.g. VMware VCSA appliance bundles).

Storage UX:
- Available images listed alphabetically by filename.
- Upload gains a Cancel button (aborts the chunk + discards the partial).
- beforeunload warning while an upload is in flight.

263 tests pass, clippy clean. NOTE: actual Windows boot is validated on
real hardware — code/script/range-serving are validated here.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-04 21:24:15 -04:00
Miles WardandClaude Opus 4.8 ac433b30e9 v0.5.7: skip PNG boot-menu background on legacy BIOS clients
The menu emitted `console --picture … || console`, relying on the
trailing `|| console` to recover on iPXE builds without IMAGE_PNG +
CONSOLE_FRAMEBUFFER. On legacy BIOS (`undionly.kpxe`, no PNG) the
`--picture` attempt misbehaves before the fallback can recover — it
tries to set a framebuffer mode the BIOS console can't honour — so the
boot menu fails to render on BIOS clients.

Fix: gate the command on `iseq ${platform} efi`, so BIOS (`pcbios`)
clients never issue `console --picture` at all and drop straight to the
plain text menu, while UEFI clients still get the graphical background.
This is automatic and per-client — a mixed BIOS+UEFI fleet each gets the
right treatment with no operator toggle. A PNG-less UEFI build (upstream
i386-efi) still falls back gracefully through the same `|| console`.

Menu snapshot updated to match. 254 tests pass, clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 19:28:02 -04:00
Miles WardandClaude Opus 4.8 c0d17fa9ca v0.5.6: advertise the HTTP port in client-facing boot URLs
The base URL handed to PXE clients was built as `http://{ip}` with no
port, ignoring OPENPXE_HTTP_PORT. Every client-facing URL derives from
it — the DHCP-proxy iPXE filename, UEFI HTTP boot, and the boot menu's
kernel/initrd/ISO links — so any non-80 deployment told clients to fetch
:80 (the wrong service). On Unraid that's the webGUI, which 301s to
https; iPXE (no TLS) then fails the chain with "Operation not supported".
This broke the exact configuration the Unraid template recommends
(HTTP port 4200, to avoid the webGUI on :80).

Fix: build_public_base_url(ip, port) includes the port unless it's 80,
so http://10.0.0.5 stays clean while http://10.0.0.5:4200 is reachable.
One source of truth, so the whole URL surface is corrected at once.
Regression-tested (port included for 4200/8080, omitted for 80).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 18:49:50 -04:00
Miles WardandClaude Opus 4.8 edf3a69daa docs: rewrite README — production/VC-ready, logo + v0.5.5 feature set
Replaces the stale v0.4.1 README with a polished, accurate overview:
centered brand-mark header + tagline + badges, a "Why OpenPXE" pitch,
a scannable Highlights section, and a "Built in Rust" section framed on
real properties (single ~18MB static musl binary, no GC, async Tokio,
workspace-wide unsafe deny, OpenSSL-free pure-Rust crypto, sub-minute
zigbuild images).

Surfaces everything shipped since v0.4.1: SMB + NFS + SFTP remote ISO
libraries (with a comparison table), SAML SSO, branding, notifications,
unattended installs, per-MAC host bindings, and layered figment config.
Quick-start, env table, OpenShift, and health/observability all updated
to v0.5.5. Adds docs/openpxe-logo.svg (render-safe static copy of the
web-UI mark) for the header.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 12:07:31 -04:00
11 changed files with 471 additions and 306 deletions
Generated
+8 -8
View File
@@ -2669,7 +2669,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openpxe"
version = "0.5.5"
version = "0.5.8"
dependencies = [
"anyhow",
"axum",
@@ -2691,7 +2691,7 @@ dependencies = [
[[package]]
name = "openpxe-core"
version = "0.5.5"
version = "0.5.8"
dependencies = [
"anyhow",
"base64",
@@ -2718,7 +2718,7 @@ dependencies = [
[[package]]
name = "openpxe-dhcp-proxy"
version = "0.5.5"
version = "0.5.8"
dependencies = [
"anyhow",
"bytes",
@@ -2732,7 +2732,7 @@ dependencies = [
[[package]]
name = "openpxe-http-api"
version = "0.5.5"
version = "0.5.8"
dependencies = [
"anyhow",
"axum",
@@ -2768,7 +2768,7 @@ dependencies = [
[[package]]
name = "openpxe-ipxe-assets"
version = "0.5.5"
version = "0.5.8"
dependencies = [
"openpxe-core",
"rust-embed",
@@ -2778,7 +2778,7 @@ dependencies = [
[[package]]
name = "openpxe-iso-store"
version = "0.5.5"
version = "0.5.8"
dependencies = [
"anyhow",
"bcrypt",
@@ -2807,7 +2807,7 @@ dependencies = [
[[package]]
name = "openpxe-tftp"
version = "0.5.5"
version = "0.5.8"
dependencies = [
"anyhow",
"bytes",
@@ -2821,7 +2821,7 @@ dependencies = [
[[package]]
name = "openpxe-webui"
version = "0.5.5"
version = "0.5.8"
[[package]]
name = "p256"
+1 -1
View File
@@ -12,7 +12,7 @@ members = [
]
[workspace.package]
version = "0.5.5"
version = "0.5.8"
edition = "2021"
rust-version = "1.95"
license = "MIT OR Apache-2.0"
+200 -231
View File
@@ -1,301 +1,270 @@
# OpenPXE
<p align="center">
<img src="docs/openpxe-logo.svg" alt="OpenPXE" width="104" height="104" />
</p>
Container-native PXE boot server. A Rust reimplementation of
[iVentoy (ventoy/PXE)](https://github.com/ventoy/PXE), designed from scratch
for Docker/OCI and OpenShift. Upload `.iso` files via the web UI; network
clients PXE-boot them.
<h1 align="center">OpenPXE</h1>
> **Status:** v0.4.1 / 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, Prometheus `/metrics`,
> light/dark theme toggle, animated OpenPXE imaging-progress widget,
> chunked ISO uploads, and per-ISO boot passwords. The test suite and
> clippy are part of the release checklist. Ready for real-hardware validation.
<p align="center">
<strong>Container-native network boot &amp; OS deployment — built in Rust.</strong>
</p>
## Design non-negotiables
<p align="center">
Drag in an ISO. PXE-boot and image an entire fleet from a browser.<br/>
No iPXE scripting. No <code>dnsmasq</code> + <code>tftpd</code> + Samba glue. No glibc. No garbage collector.
</p>
1. **Fully offline / air-gap deployable.** Zero CDN assets. Zero external
HTTP calls from the server, the browser, or the generated iPXE scripts.
Build the container once, run forever disconnected.
2. **iPXE is a backend implementation detail.** No `.ipxe` upload path, no
manual script editing, no iPXE terminology in the UI. Every knob in the
web UI maps to a specific script-generation behavior inside the binary.
3. **The client trust store is off-limits.** No test-signed drivers, no
`bcdedit /set testsigning on`, no certificates injected into WinPE or
the target OS.
<p align="center">
<img alt="release" src="https://img.shields.io/badge/release-v0.5.5-2874d7" />
<img alt="license" src="https://img.shields.io/badge/license-MIT%20%7C%20Apache--2.0-59824f" />
<img alt="rust" src="https://img.shields.io/badge/built%20with-Rust-fb8841?logo=rust&logoColor=white" />
<img alt="container" src="https://img.shields.io/badge/container--native-OCI%20%C2%B7%20OpenShift-2496ED?logo=docker&logoColor=white" />
<img alt="binary" src="https://img.shields.io/badge/static-musl%20%C2%B7%20~18MB-330f1f" />
</p>
## What it does
---
1. **DHCP proxy** (RFC 4578). Coexists with your existing DHCP server —
never assigns IPs. Listens on UDP 67 + UDP 4011.
2. **TFTP server** (RFC 1350 + RFC 2347/2348/2349/7440 option negotiation)
that serves architecture-specific iPXE binaries to firmware PXE ROMs.
3. **HTTP server** that serves the web UI, the generated iPXE boot scripts,
raw ISOs (with Range), and files inside ISOs without prior extraction.
4. **ISO introspection**: auto-detects the distro family and generates the
appropriate kernel+initrd or wimboot chain. No manual config.
5. **Hierarchical PXE menu** mirroring the Phase 2 spec:
```
Default > Boot from Local HDD
Installers > Linux Installers / Windows Installers
Tools > Utilities / OpenPXE Shell / Network Card Info
Queued Deployment
```
6. **Queued Deployment queue** — the coordinated 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
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
skips the menu, chains straight through.
9. **Prometheus metrics** at `/metrics` — DHCP replies by arch, TFTP
transfer counts and bytes, HTTP request counts by route, queue /
imaging gauges, uptime, build info. Plain text exposition format,
no external metrics framework dependency.
8. **Settings API** lets you change the default boot-menu timeout (default
600s), the timeout action (stay / Local HDD / Queued Deployment), and
feature toggles like Windows ISO support. The iPXE scripts regenerate
on every request using current settings.
OpenPXE turns bare-metal provisioning into a single container with a web UI. It's a
ground-up Rust reimplementation of [iVentoy (ventoy/PXE)](https://github.com/ventoy/PXE),
designed for Docker/OCI and OpenShift instead of a Windows desktop — so it drops onto
an Unraid box, a Linux server, or a Kubernetes cluster and just runs.
### Architectures supported on day one
Upload `.iso` files (or point at a remote share), and any machine on the network boots
them — Linux installers, live tools, or stock Windows setup — with **zero iPXE knowledge
required by the operator.**
| DHCP option 93 | Architecture | Binary served |
|----------------|-----------------|-------------------------|
| `0x0000` | Legacy x86 BIOS | `undionly.kpxe` |
| `0x0006` | IA32 UEFI | `snponly-i386.efi` |
| `0x0007`/`0x0009` | x86_64 UEFI | `snponly.efi` |
| `0x000B` | ARM64 UEFI | `snponly-arm64.efi` |
> **Status — v0.5.5, late pre-beta.** The full PXE stack, web UI, remote ISO libraries
> (SMB/NFS/SFTP), Windows deployment, queued fleet rollout, SAML SSO, and Prometheus
> metrics are implemented and test-covered. The release checklist gates every tag on the
> full test suite + `clippy`. Currently in real-hardware validation.
UEFI firmware that sends `HTTPClient` in option 60 is handled too — we
skip TFTP and respond with an HTTP URL.
## Why OpenPXE
## Quick start — MVP container (recommended)
Standing up network boot the traditional way means hand-wiring `dnsmasq`, a TFTP daemon,
hand-written iPXE menu scripts, an HTTP server, and Samba — then keeping that fragile
stack alive, and discovering none of it containerizes cleanly (kernel-mount NFS, raw
sockets, `CAP_SYS_ADMIN`). iVentoy solved the UX beautifully, but it's a Windows GUI app.
OpenPXE collapses that whole stack into **one statically-linked binary in one container**:
- **A web UI does everything.** iPXE is an internal implementation detail — there is no
script upload, no `.ipxe` editing, no PXE jargon in the interface.
- **It runs anywhere a container runs.** No kernel modules, no privileged mode — proxy-mode
DHCP + `NET_BIND_SERVICE` is the entire requirement. Verified on Unraid, plain Docker,
and OpenShift's restricted SCC.
- **It's air-gap native.** Zero CDN assets, zero outbound calls from the server, browser,
or generated boot scripts. Build the image once, run it forever, disconnected.
## Highlights
#### Boot stack
- **DHCP proxy** (RFC 4578) that coexists with your existing DHCP — it never hands out IPs.
- **TFTP** (RFC 1350 + 2347/2348/2349/7440 option negotiation) serving arch-correct iPXE firmware.
- **HTTP** serving the UI, generated boot scripts, raw ISOs (with byte-range), and files
*inside* ISOs with no prior extraction.
- **Graphical iPXE boot menu** built from your uploads, with a PNG background and a clean
hierarchy — generated fresh on every request from current settings.
#### ISO management & remote libraries
- **Drag-and-drop chunked uploads** that don't 502 on multi-GB images.
- **Automatic introspection** — detects the distro family and generates the right
kernel+initrd or Windows `wimboot` chain. No manual config.
- **Remote ISO libraries, streamed on demand** (no local cache) over **SMB, NFS, or SFTP**
see the table below.
#### Fleet deployment
- **Queued Deployment** — clients join a queue and wait; the operator fires one image at
every waiting machine simultaneously.
- **Per-MAC host bindings** — pin a MAC straight to a target (with optional auto hostname,
auto IP, and an unattended answer file); it skips the menu and chains through.
- **Unattended installs** — upload Kickstart / Preseed / Autoinstall / Windows answer files;
they're templated per-host (hostname / IP / MAC) and served only to booting clients.
- **Windows deployment** from a stock Microsoft ISO — **every binary the client runs stays
Microsoft-signed** (details below).
#### Operations & access
- **SAML 2.0 single sign-on** (pure-Rust SP, no OpenSSL/xmlsec) alongside local accounts.
- **Custom branding** — light / dark / PXE-client logos and favicon.
- **Notifications** — Slack / Teams / Discord webhooks and SMTP email on boot events.
- **Prometheus `/metrics`**, a built-in operator **terminal**, live tracing log, and
`/healthz` · `/readyz` probes.
- **Layered config** — defaults → TOML file → `OPENPXE_*` env, in that order.
## Built in Rust
Rust isn't a checkbox here — it's why OpenPXE deploys the way it does:
- **One static binary, ~18 MB.** Compiled to `x86_64-unknown-linux-musl` — no glibc, no
interpreter, no sidecar runtime. The runtime image is "binary + a few CLI tools."
- **No garbage collector, async throughout.** A Tokio runtime drives DHCP, TFTP, HTTP, and
many concurrent multi-GB ISO streams on a tiny, predictable memory footprint — it idles
near-zero and never GC-pauses mid-transfer.
- **Memory-safe by construction.** `unsafe` is **denied workspace-wide**; the only
exceptions are two small, individually-audited FFI calls (`statvfs` for disk usage and a
Samba `SIGHUP`).
- **OpenSSL-free, pure-Rust crypto.** TLS via `rustls`/`ring`; the SAML Service Provider
does XML-DSig verification with RustCrypto — no `xmlsec`, no `libxml2`, no C crypto to
CVE-patch. Even the SMB/NFS/SFTP clients avoid C libraries.
- **Sub-minute, reproducible container builds.** Cross-compiled with `cargo-zigbuild`
(zig as the linker) — a full image builds in well under a minute on a warm cache, with
no QEMU emulation.
## Quick start
### Run the container
```bash
# 1. Pull bundled iPXE binaries (~2 MB, one-time).
./scripts/fetch-ipxe.sh
# Build the self-contained image (iPXE binaries are fetched + built inside the Dockerfile).
docker build -f deploy/docker/Dockerfile -t openpxe:0.5.5 .
# 2. Build the container image (~3 min first time).
docker buildx build -f deploy/docker/Dockerfile -t openpxe:0.4.1 --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.
docker run -d --name openpxe \
--network host \
# Run it on the box plugged into your PXE network. Host networking is required in
# proxy mode so the container sees DHCPDISCOVER broadcasts; set PUBLIC_IP to this
# host's LAN address so advertised boot URLs are reachable.
docker run -d --name openpxe --network host \
-e OPENPXE_PUBLIC_IP=10.0.0.5 \
-e OPENPXE_DHCP_MODE=proxy \
-v $PWD/data/isos:/var/lib/openpxe/isos \
-v $PWD/data/work:/var/lib/openpxe/work \
openpxe:0.4.1
openpxe:0.5.5
# 4. Open the UI and drop an ISO in.
# Open the UI and drop an ISO in.
open http://10.0.0.5
```
Host networking is required in proxy mode so the container sees DHCPDISCOVER
broadcasts from the PXE VLAN. On macOS/Windows hosts Docker runs in a Linux
VM, so "host" means the VM — use `openpxe-dev` in `docker-compose.yml` for
API-only testing on a laptop.
> On macOS/Windows, Docker runs inside a Linux VM, so "host network" means the VM — use
> the `openpxe-dev` service in `docker-compose.yml` for API-only testing on a laptop:
> `OPENPXE_PUBLIC_IP=127.0.0.1 docker compose up openpxe-dev`.
### Quick start — docker compose
### Build from source
```bash
# MVP / API testing on a laptop (no DHCP, high ports):
OPENPXE_PUBLIC_IP=127.0.0.1 docker compose up openpxe-dev
# Real PXE deployment on a Linux host (host network, DHCP proxy on):
OPENPXE_PUBLIC_IP=10.0.0.5 docker compose up openpxe
./scripts/fetch-ipxe.sh # populate assets/ipxe/ (embedded at compile time)
cargo run --release # needs root or CAP_NET_BIND_SERVICE for :80/:69
```
### Multi-arch build + push
For deploying to x86_64 servers, build both arches in one manifest:
```bash
# One-time: bootstrap a multi-arch builder.
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.4.1 \
--push \
-f deploy/docker/Dockerfile .
```
On an Apple Silicon host, the amd64 stage runs under QEMU emulation (~10-15 min for a cold cache). On a Linux x86_64 host, both arches build natively at normal speed. CI runners on GitHub Actions with `docker/build-push-action@v5` handle this cleanly.
### Build from source (no container)
```bash
./scripts/fetch-ipxe.sh
cargo run --release # needs NET_BIND_SERVICE or root for :80/:69
```
### Container health probes
| Endpoint | Purpose |
|-------------|---------------------------------------------------------------|
| `/healthz` | Liveness — HTTP stack alive. Always 200. |
| `/readyz` | Readiness — 200 only if iPXE binaries bundled + ISO dir OK. |
| `/api/status` | Full JSON status: versions, assets, counts, live settings, SMB state. |
### Pre-seeding ISOs from a directory
For CI, pre-baked homelab deployments, or a fresh PVC, the binary has a
`seed` subcommand that imports every `*.iso` from a host path through the
same pipeline the web UI uses (introspection + boot-entry generation):
### Pre-seed ISOs from a directory
```bash
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.4.1 seed --from /seed
# Dry run first to see what would be imported:
docker run --rm -v /my/iso-library:/seed:ro openpxe:0.4.1 seed --from /seed --dry-run
openpxe:0.5.5 seed --from /seed # add --dry-run to preview
```
### Environment overrides
## Remote ISO libraries
| Var | Default | Meaning |
|------------------------|-----------------------------|----------------------------------------|
| `OPENPXE_HTTP_PORT` | `80` | Web UI + boot script HTTP port |
| `OPENPXE_TFTP_PORT` | `69` | TFTP port |
| `OPENPXE_DHCP_PORT` | `67` | DHCP server-side port |
| `OPENPXE_DHCP_MODE` | `proxy` | `proxy` or `disabled` |
| `OPENPXE_PUBLIC_IP` | auto-detect | Advertised IP for clients. Startup **fails** if unset and auto-detect returns loopback. |
| `OPENPXE_ISO_DIR` | `/var/lib/openpxe/isos` | Where uploaded ISOs live |
| `OPENPXE_WORK_DIR` | `/var/lib/openpxe/work` | Scratch + runtime settings |
| `OPENPXE_LOG` | `info,openpxe=debug` | `tracing` filter |
Point OpenPXE at a NAS and boot ISOs straight off it — **read on demand, no local copy**,
so a 50-ISO library costs zero disk on the OpenPXE host. All three clients are userspace
(no kernel mounts, no `CAP_SYS_ADMIN`); pick whichever your storage speaks.
## What the boot menu looks like on a real client
| Protocol | Implementation | Auth | HTTP Range¹ |
|----------|----------------|------|-------------|
| **NFS** (v3) | Pure-Rust in-process client | Client-IP (server export list) | ✅ |
| **SFTP** (SSH) | Pure-Rust in-process client (`russh`) | Password **or** SSH key · host-key TOFU | ✅ |
| **SMB** / CIFS | Userspace `smbclient` | Guest or username/password | — |
¹ Range support lets clients seek into a multi-GB ISO without downloading what comes
before it — needed for kernel/initrd extraction and `httpdisk`-style boots. NFS and SFTP
expose explicit offsets; the SMB CLI streams sequentially, so SMB-sourced ISOs serve whole-file.
## Supported client architectures
| DHCP option 93 | Architecture | Firmware served |
|----------------|--------------|-----------------|
| `0x0000` | Legacy x86 BIOS | `undionly.kpxe` |
| `0x0006` | IA32 UEFI | `snponly-i386.efi` |
| `0x0007` / `0x0009` | x86_64 UEFI | `snponly.efi` |
| `0x000B` | ARM64 UEFI | `snponly-arm64.efi` |
UEFI firmware that advertises `HTTPClient` (option 60) skips TFTP entirely and is handed an HTTP URL.
## The boot menu, on a real client
```
OpenPXE - network boot menu
OpenPXE network boot menu
------------------------- Default -------------------------
Boot from Local HDD
----------------------- Installers -----------------------
----------------------- Installers ------------------------
Linux Installers >
Windows Installers > (only if enabled in Settings)
-------------------------- Tools --------------------------
Tools > Utilities / Shell /
NIC Info / Reboot /
Exit and continue BIOS
Tools > Utilities / OpenPXE Shell / NIC Info / Reboot
---------------------- Queued Deployment ------------------
Queued Deployment (join queue)
```
Linux/Windows submenus show file sizes iVentoy-style:
Linux/Windows submenus list images iVentoy-style with sizes:
```
OpenPXE - Linux Installers
OpenPXE Linux Installers
[ 4376 MB] CentOS-7-x86_64-DVD-1810
[ 2002 MB] Fedora-Workstation-Live-x86_64-38-1.6
[ 4699 MB] ubuntu-22.04.2-desktop-amd64
[ 2002 MB] Fedora-Workstation-Live-x86_64-38-1.6
< Back to main menu
```
iPXE never appears in the UI — the whole hierarchy above is generated from
ISOs you upload via drag-and-drop in the web UI plus toggles in Settings.
The entire hierarchy is generated from what you upload and toggle — iPXE never surfaces.
## Windows deployment
Enable **Windows ISO support** in Settings, then upload a **stock, unmodified** Microsoft ISO:
1. On upload, OpenPXE uses `wimlib-imagex` to inject exactly two plain-text files into the
WinPE image (`winpeshl.ini` + `startnet.cmd`) — no drivers, no certificates.
2. The container's Samba `smbd` serves the extracted install tree on `:445`.
3. The client chainloads `wimboot` → patched WinPE → Windows Setup running off the share.
**Every executable the client runs is stock Microsoft-signed.** OpenPXE never ships
drivers, never installs certificates into the client trust store, and never recommends
`bcdedit /set testsigning on`. The SMB approach is adapted (re-implemented, not copied)
from [Bootimus](https://github.com/garybowers/bootimus) (Apache-2.0). Port `445` must be
directly reachable from clients; Windows 10/11 client SKUs are the tested target.
## Configuration
All settings have defaults and layer **defaults → TOML (`--config` / `OPENPXE_CONFIG`) →
`OPENPXE_*` env**. The common knobs:
| Var | Default | Meaning |
|-----|---------|---------|
| `OPENPXE_PUBLIC_IP` | auto-detect | IP advertised to clients. **Startup fails** if unset and auto-detect yields loopback. |
| `OPENPXE_DHCP_MODE` | `proxy` | `proxy` or `disabled` |
| `OPENPXE_HTTP_PORT` | `80` | Web UI + boot-script HTTP port |
| `OPENPXE_TFTP_PORT` | `69` | TFTP port |
| `OPENPXE_DHCP_PORT` | `67` | DHCP server-side port |
| `OPENPXE_ISO_DIR` | `/var/lib/openpxe/isos` | Uploaded ISOs |
| `OPENPXE_WORK_DIR` | `/var/lib/openpxe/work` | Scratch, settings, share + branding state |
| `OPENPXE_LOG` | `info,openpxe=info` | `tracing` filter |
## OpenShift
```bash
oc apply -f deploy/openshift/
oc -n openpxe get all
oc -n openpxe get route openpxe -o jsonpath='{.spec.host}'
```
### Why a custom SCC?
The bundled `openpxe-scc` grants exactly `hostNetwork` (CNI overlays don't deliver L2
broadcast into pod netns) and `NET_BIND_SERVICE` (to bind ports <1024) — nothing else.
No raw sockets, no privileged mode. The Route covers `80/TCP`; PXE clients reach UDP
67/69/4011 on the node's host IP directly.
The default `restricted-v2` blocks `hostNetwork` and all capabilities. PXE
cannot work without host network (CNI overlays don't deliver L2 broadcast
into pod netns), and we need `NET_BIND_SERVICE` to bind <1024. The custom
`openpxe-scc` grants exactly those two and nothing else. No raw sockets,
no privileged mode — proxy-mode DHCP sidesteps the usual requirements.
## Health & observability
### What's on host ports
| Port | Proto | Purpose |
|----------|-------|---------------------------------|
| 67 | UDP | DHCP server (proxy replies) |
| 69 | UDP | TFTP |
| 4011 | UDP | PXE Boot Server discovery |
| 80 | TCP | Web UI + HTTP boot assets |
The OpenShift Route only covers 80/TCP. Clients on the PXE network talk to
the node's host IP directly for UDP.
## Windows support
Enabled by toggling **Windows ISO support** under Settings. The flow:
1. Upload a stock Microsoft Windows install ISO (vanilla, no pre-processing).
2. On upload, OpenPXE extracts the ISO and uses `wimlib-imagex` to rewrite
image index 2 (WinPE) of `sources/boot.wim`. It injects exactly two
plain-text files:
- `Windows/System32/winpeshl.ini` — tells WinPE to run `startnet.cmd`.
- `Windows/System32/startnet.cmd` — runs `wpeinit`, waits for the SMB
host to be reachable, `net use Z: \\<server>\<share> /user:guest`,
then `Z:\setup.exe`.
3. The container's Samba `smbd` serves the extracted install tree on :445.
4. The client gets chainloaded into wimboot → patched WinPE → Windows Setup
running off the SMB share. **Every binary the client executes is stock
Microsoft-signed.**
### What we never do
- Ship drivers — signed, test-signed, or otherwise — that load on the client.
- Install certificates into the target's trust store or WinPE boot policy.
- Recommend `bcdedit /set testsigning on` or any equivalent signing-policy
weakening.
### Credit & limitations
The SMB-based approach is adapted from [Bootimus](https://github.com/garybowers/bootimus)
(Apache-2.0). Re-implemented in Rust; no code was copied verbatim. Known
operational constraints inherited from the design:
- **Port 445 must be directly reachable from PXE clients.** `net use`
ignores alternate ports. In OpenShift this means `hostPort: 445` on the
deployment; on a host that already runs SMB it will collide.
- Windows 10/11 client SKUs are the tested target. Server SKUs untested.
- Hardware with NICs/storage controllers missing from WinPE's bundled
drivers will need a driver-pack injection step (not yet implemented).
## Queued Deployment
The coordinated 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`).
2. The client joins the queue, gets a numbered queue position, and enters a
long-poll loop (25s per request, auto-renewed).
3. In the web UI's **Queued Deployment** tab, the operator sees each waiting
client with its MAC, IP, arch, and position.
4. The operator selects an image and clicks **Launch for all waiting**.
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.
No user-facing iPXE anywhere in this flow. The client only ever runs
scripts we generate; the operator only interacts with the web UI.
| Endpoint | Purpose |
|----------|---------|
| `/healthz` | Liveness — always 200 if the HTTP stack is up. |
| `/readyz` | Readiness — 200 only once iPXE firmware is bundled and the ISO dir is reachable. |
| `/api/status` | Full JSON: version, assets, counts, live settings, share + SMB state. |
| `/metrics` | Prometheus text format — DHCP replies by arch, TFTP/HTTP counts, queue gauges, uptime. |
## Architecture
See [`docs/architecture.md`](docs/architecture.md) for the protocol stack,
crate layout, and the full decision log.
Workspace of focused crates — `core`, `dhcp-proxy`, `tftp`, `http-api`, `iso-store`,
`ipxe-assets`, `webui`, and the `openpxe` binary. See
[`docs/architecture.md`](docs/architecture.md) for the protocol stack, crate layout, and
the full decision log.
## Licence
## License
MIT OR Apache-2.0.
Dual-licensed under **MIT OR Apache-2.0** — use whichever fits your project.
+21 -12
View File
@@ -61,16 +61,24 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
// returns a full-screen 1024×768 PNG now — the operator's logo on a
// dark field, or a default OpenPXE mark when none is uploaded. The
// `--top 290` reserves the top band (where the logo paints) so the
// menu text lands below it. On an iPXE build *with* `IMAGE_PNG` +
// `CONSOLE_FRAMEBUFFER` (our x86_64 UEFI binaries, built from source
// — see deploy/docker/Dockerfile) this paints the background and
// overlays the menu. On a build *without* PNG support (the fetched
// BIOS/i386/arm64 binaries) the whole `console --picture …` command
// fails and the `|| console` resets to a clean full-screen text
// menu. Either way there's no ASCII placeholder anymore.
// menu text lands below it.
//
// v0.5.7: gate the whole command behind `iseq ${platform} efi`.
// `console --picture` needs IMAGE_PNG + CONSOLE_FRAMEBUFFER, which
// only our from-source UEFI binaries carry (x86_64/arm64 UEFI — see
// deploy/docker/Dockerfile). The fetched BIOS `undionly.kpxe` has
// neither, and on legacy BIOS the `--picture` attempt misbehaves
// *before* the trailing `|| console` fallback can recover (it tries
// to set a framebuffer mode the BIOS console can't honour). Guarding
// on platform means BIOS clients never issue the command at all —
// they drop straight to the plain text menu — while UEFI clients
// still get the graphical background. A PNG-less UEFI build (e.g. the
// upstream i386-efi baseline) still falls back gracefully through the
// same `|| console`. No operator toggle needed; mixed BIOS+UEFI
// fleets each get the right treatment automatically.
let _ = writeln!(
s,
"console --picture {base}/branding/pxe-logo --top 290 || console"
"iseq ${{platform}} efi && console --picture {base}/branding/pxe-logo --top 290 || console"
);
// Map iPXE's ${{buildarch}} + ${{platform}} into the human form the
// user asked for (e.g. "x86 BIOS", "x86_64 UEFI", "arm64 UEFI").
@@ -101,12 +109,13 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
} else {
let _ = writeln!(s, "item --gap -- (no Linux ISOs uploaded)");
}
if settings.windows_enabled && has_family(isos, is_windows_family) {
// v0.5.8: Windows just works — no Settings toggle. Show the Windows
// installers submenu whenever a Windows ISO is present; entries boot
// via HTTP sanboot of the raw ISO, so no SMB/extraction is required.
if has_family(isos, is_windows_family) {
let _ = writeln!(s, "item windows Windows Installers >");
} else if settings.windows_enabled {
let _ = writeln!(s, "item --gap -- (no Windows ISOs uploaded)");
} else {
let _ = writeln!(s, "item --gap -- (Windows support disabled in Settings)");
let _ = writeln!(s, "item --gap -- (no Windows ISOs uploaded)");
}
let _ = writeln!(
s,
@@ -7,7 +7,7 @@ expression: rendered
set base-url http://10.0.0.5
set esc:hex 1b
set cls ${esc:string}[2J
console --picture http://10.0.0.5/branding/pxe-logo --top 290 || console
iseq ${platform} efi && console --picture http://10.0.0.5/branding/pxe-logo --top 290 || console
set arch-label ${buildarch} ${platform}
iseq ${buildarch} i386 && iseq ${platform} pcbios && set arch-label x86 BIOS || iseq ${buildarch} x86_64 && iseq ${platform} efi && set arch-label x86_64 UEFI || iseq ${buildarch} arm64 && iseq ${platform} efi && set arch-label arm64 UEFI || true
:menu
@@ -17,7 +17,7 @@ item --gap -- ------------------------- Default -------------------------
item local Boot from Local HDD
item --gap -- ----------------------- Installers -----------------------
item --gap -- (no Linux ISOs uploaded)
item --gap -- (Windows support disabled in Settings)
item --gap -- (no Windows ISOs uploaded)
item --gap -- -------------------------- Tools --------------------------
item tools Tools >
item --gap -- ---------------------- Queued Deployment ---------------------
+20 -23
View File
@@ -693,7 +693,7 @@ async fn log_recent_returns_buffered_lines() {
}
#[tokio::test]
async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
async fn windows_iso_renders_clean_sanboot_script_with_no_trust_store_writes() {
// Synthesize an ISO with a Windows volume label + the sources/boot.wim
// sentinel so introspection labels it WindowsPe with has_boot_wim.
let mut buf = vec![0u8; 32 * 2048];
@@ -763,38 +763,35 @@ async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
"introspection should detect sources/boot.wim sentinel"
);
// The boot entry should be a wimboot kind with the canonical 5-file
// chain documented in the LinusTechTips iPXE-Windows guide.
// v0.5.8: Windows boots via iPXE HTTP sanboot of the raw ISO — no SMB,
// no extraction, no in-ISO file serving, no operator toggle. The boot
// entry is a `san_boot_iso` kind pointing at the raw image.
let entry = &meta["boot_entries"][0];
assert_eq!(entry["kind"]["kind"], "wimboot");
let files = entry["kind"]["files"].as_array().unwrap();
let names: Vec<&str> = files.iter().map(|f| f[0].as_str().unwrap()).collect();
assert!(names.contains(&"bootmgr"));
assert!(names.contains(&"bootmgr.efi"));
assert!(names.contains(&"bcd"));
assert!(names.contains(&"boot.sdi"));
assert!(names.contains(&"boot.wim"));
assert_eq!(entry["kind"]["kind"], "san_boot_iso");
let iso_url = entry["kind"]["iso_url"].as_str().unwrap();
assert!(
std::path::Path::new(iso_url)
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("iso")),
"sanboot should target the raw ISO, got: {iso_url}"
);
// Render the entry script and verify:
// 1. It uses wimboot
// 2. All 5 files are referenced via `initrd --name`
// 3. NO trust-store / driver / testsigning operations slip in
// 1. It uses `sanboot` against the raw ISO over HTTP
// 2. NO trust-store / driver / testsigning operations slip in
let entry_id = entry["id"].as_str().unwrap();
let url = format!("/boot/{entry_id}.ipxe");
let (s, body) = get(&app, &url).await;
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}"
script.contains("sanboot"),
"missing sanboot line:\n{script}"
);
assert!(
script.contains(&format!("/{iso_url}")),
"sanboot should reference the raw ISO url:\n{script}"
);
for tag in ["bootmgr", "bootmgr.efi", "bcd", "boot.sdi", "boot.wim"] {
assert!(
script.contains(&format!("initrd --name {tag}")),
"missing `initrd --name {tag}` line:\n{script}"
);
}
// Hard guarantees we never want to see in any client-facing script.
let lower = script.to_lowercase();
for forbidden in [
+101 -3
View File
@@ -85,12 +85,37 @@ pub fn introspect(path: &Path) -> IntrospectionReport {
read_total += n;
}
// `sources/boot.wim` is the definitive Windows-install-media marker
// when the ISO exposes ASCII (ISO9660/Joliet) names. `contains_ascii`
// is case-insensitive, so one form covers BOOT.WIM / boot.wim and the
// backslash variant.
if contains_ascii(&haystack, b"sources/boot.wim")
|| contains_ascii(&haystack, b"SOURCES/BOOT.WIM")
|| contains_ascii(&haystack, b"SOURCES\\BOOT.WIM")
|| contains_ascii(&haystack, b"sources\\boot.wim")
{
report.has_boot_wim = true;
if report.family == DistroFamily::Unknown {
report.family = DistroFamily::WindowsPe;
}
// v0.5.8: broaden Windows detection. Modern Windows 10/11 ISOs are
// UDF — filenames are stored as UTF-16 (so the ASCII scan above misses
// them) and the volume label is a cryptic Microsoft string (so
// `family_from_label` misses it too). Booting is via HTTP sanboot of
// the raw ISO (no boot.wim extraction), so we only need the *family*.
// Catch the common cases: well-known Windows markers in either ASCII
// or UTF-16LE within the first 16 MiB, plus a filename hint.
if report.family == DistroFamily::Unknown {
let head = &haystack[..haystack.len().min(16 * 1024 * 1024)];
let ascii_markers: [&[u8]; 4] = [
b"bootmgr",
b"sources/install.wim",
b"sources/install.esd",
b"efi/microsoft",
];
let utf16_markers = ["bootmgr", "boot.wim", "install.wim", "microsoft"];
let looks_windows = ascii_markers.iter().any(|m| contains_ascii(head, m))
|| utf16_markers.iter().any(|m| contains_utf16le_ci(head, m))
|| filename_looks_windows(path);
if looks_windows {
report.family = DistroFamily::WindowsPe;
}
}
@@ -158,6 +183,45 @@ fn contains_ascii(haystack: &[u8], needle: &[u8]) -> bool {
.any(|w| w.eq_ignore_ascii_case(needle))
}
/// Case-insensitive search for an ASCII string encoded as UTF-16LE — the
/// way UDF (and thus modern Windows ISOs) store filenames. Each character
/// is two bytes: the ASCII low byte (compared case-insensitively) followed
/// by a 0 high byte. v0.5.8.
fn contains_utf16le_ci(haystack: &[u8], ascii: &str) -> bool {
let n = ascii.len();
if n == 0 || haystack.len() < n * 2 {
return false;
}
let lower: Vec<u8> = ascii.bytes().map(|b| b.to_ascii_lowercase()).collect();
haystack.windows(n * 2).any(|w| {
lower
.iter()
.enumerate()
.all(|(i, &c)| w[i * 2 + 1] == 0 && w[i * 2].to_ascii_lowercase() == c)
})
}
/// Filename heuristic: a stock Windows ISO almost always carries an obvious
/// token in its name (e.g. `..._windows_11_...`, `Win10`, `winserver`).
/// Used only as a last-resort family hint when the content scan and volume
/// label are inconclusive. v0.5.8.
fn filename_looks_windows(path: &Path) -> bool {
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_ascii_lowercase();
const TOKENS: [&str; 6] = [
"windows",
"winpe",
"win10",
"win11",
"winserver",
"win-server",
];
TOKENS.iter().any(|t| name.contains(t))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -179,4 +243,38 @@ mod tests {
assert_eq!(family_from_label("ARCH_202604"), DistroFamily::Arch);
assert_eq!(family_from_label("weird-custom"), DistroFamily::Unknown);
}
#[test]
fn utf16le_marker_matches_case_insensitively() {
// "boot.wim" encoded UTF-16LE, mixed case — UDF stores Windows
// filenames this way, which the ASCII scan can't see.
let s = "BOOT.WIM";
let utf16: Vec<u8> = s.bytes().flat_map(|b| [b, 0]).collect();
let mut hay = vec![0u8; 8];
hay.extend_from_slice(&utf16);
hay.extend_from_slice(&[1, 2, 3]);
assert!(contains_utf16le_ci(&hay, "boot.wim"));
assert!(contains_utf16le_ci(&hay, "Boot.Wim"));
assert!(!contains_utf16le_ci(&hay, "install.wim"));
// An ASCII (not UTF-16) occurrence must NOT match the UTF-16 scan.
assert!(!contains_utf16le_ci(b"boot.wim plain ascii", "boot.wim"));
}
#[test]
fn filename_hint_catches_windows_isos() {
use std::path::Path;
assert!(filename_looks_windows(Path::new(
"en-us_windows_11_iot_enterprise_ltsc_2024_x64_dvd.iso"
)));
assert!(filename_looks_windows(Path::new(
"Win10_22H2_English_x64.iso"
)));
assert!(filename_looks_windows(Path::new("winserver2022.iso")));
assert!(!filename_looks_windows(Path::new(
"ubuntu-24.04-desktop.iso"
)));
assert!(!filename_looks_windows(Path::new(
"Rocky-9.4-x86_64-dvd.iso"
)));
}
}
+15 -15
View File
@@ -557,22 +557,22 @@ fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> V
.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
// `iso/<id>/<path>` to on-disk extraction via ISO9660 lookup.
let base = format!("iso/{id}");
DistroFamily::WindowsPe => {
// v0.5.8: boot Windows directly via iPXE HTTP sanboot. iPXE
// exposes the raw ISO as an emulated CD backed by on-demand
// HTTP range reads, and Windows Setup boots from it. This
// replaces the old wimboot+SMB chain, which (a) needed an SMB
// server the host often can't provide (port 445 collisions),
// (b) served in-ISO files via an ISO9660 lookup that failed on
// UDF-only Windows 11 ISOs, and (c) required an operator
// toggle. sanboot needs none of that — just the HTTP port,
// which works in any environment. The unmodified, stock ISO is
// served at iso/<id>.iso; nothing is injected into Windows.
vec![BootEntry {
id: format!("{id}-winpe"),
title: format!("{title} (Windows / wimboot)"),
kind: BootKind::Wimboot {
wimboot_url: "ipxe/wimboot".to_string(),
files: vec![
("bootmgr".into(), format!("{base}/bootmgr")),
("bootmgr.efi".into(), format!("{base}/bootmgr.efi")),
("bcd".into(), format!("{base}/boot/bcd")),
("boot.sdi".into(), format!("{base}/boot/boot.sdi")),
("boot.wim".into(), format!("{base}/sources/boot.wim")),
],
id: format!("{id}-windows"),
title: format!("{title} (Windows)"),
kind: BootKind::SanBootIso {
iso_url: format!("iso/{id}.iso"),
},
}]
}
+44 -1
View File
@@ -94,7 +94,13 @@ async fn main() -> anyhow::Result<()> {
}
},
};
let public_base_url = format!("http://{our_ip}");
// v0.5.6: the advertised base URL must carry the HTTP port. Every
// client-facing URL (the DHCP-proxy iPXE filename, UEFI HTTP boot,
// and the menu's kernel/initrd/ISO links) is derived from this one
// string, so omitting the port silently pointed PXE clients at :80 —
// breaking every non-80 deployment (e.g. the Unraid template's 4200,
// chosen to dodge the webGUI). See `build_public_base_url`.
let public_base_url = build_public_base_url(our_ip, config.server.http_port);
let iso_store = IsoStore::new(config.paths.iso_dir.clone());
iso_store.load_from_disk().await?;
@@ -345,6 +351,20 @@ async fn seed_from_dir(
/// a loopback address (which would give every PXE client an unreachable
/// `http://127.0.0.1/...`). Users in multi-homed setups should set
/// `OPENPXE_PUBLIC_IP` explicitly.
/// Build the base URL advertised to PXE clients. The port is included
/// unless it's the HTTP default (80), keeping the common case clean
/// (`http://10.0.0.5`) while a remapped port (`http://10.0.0.5:4200`)
/// stays reachable. This is the single source of truth for every
/// client-facing URL — the DHCP-proxy iPXE filename, UEFI HTTP boot, and
/// the boot menu's kernel/initrd/ISO links all derive from it.
fn build_public_base_url(ip: Ipv4Addr, http_port: u16) -> String {
if http_port == 80 {
format!("http://{ip}")
} else {
format!("http://{ip}:{http_port}")
}
}
fn detect_primary_ipv4() -> Option<Ipv4Addr> {
// First try: route to the public internet. `UdpSocket::connect` to a
// well-known external address causes the OS to populate `local_addr`
@@ -478,3 +498,26 @@ fn prefix_to_dotted(prefix: u8) -> String {
mask & 0xff
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn public_base_url_includes_non_default_port() {
// The v0.5.6 regression guard: a remapped HTTP port (e.g. the
// Unraid template's 4200) MUST appear in the advertised URL, or
// PXE clients fetch :80 — the wrong service — and boot fails.
let ip: Ipv4Addr = "192.168.1.49".parse().unwrap();
assert_eq!(build_public_base_url(ip, 4200), "http://192.168.1.49:4200");
assert_eq!(build_public_base_url(ip, 8080), "http://192.168.1.49:8080");
}
#[test]
fn public_base_url_omits_default_port() {
// Port 80 stays clean (no `:80`) so the common case reads nicely
// and matches what every browser/iPXE assumes by default.
let ip: Ipv4Addr = "10.0.0.5".parse().unwrap();
assert_eq!(build_public_base_url(ip, 80), "http://10.0.0.5");
}
}
+38 -10
View File
@@ -160,15 +160,18 @@
// tint borrowed from Bootimus v0.1.62. Returns {ok, reason}.
function bootability(iso, settings) {
const fam = iso.introspection.family;
const isWin = fam === 'windows_pe';
if (isWin && !settings.windows_enabled) {
return { ok: false, reason: 'Windows boot disabled in Settings' };
// v0.5.8: Windows ISOs boot via iPXE HTTP sanboot of the raw image —
// no Settings toggle, no SMB, no size limit. Always bootable.
if (fam === 'windows_pe') {
return { ok: true };
}
if (!isWin && !iso.introspection.kernel_path && fam !== 'windows_pe') {
// Linux without a detected kernel falls through to sanboot which
// rarely works for >1 GiB ISOs.
if (!iso.introspection.kernel_path) {
// Not Windows and no Linux kernel/initrd detected. Small images can
// still try the sanboot fallback; large ones almost certainly aren't
// network-bootable installers (e.g. appliance bundles like VMware
// VCSA) — flag them clearly instead of with a Linux-centric message.
if (iso.size_bytes > 1.5 * 1024 * 1024 * 1024) {
return { ok: false, reason: 'no kernel/initrd detected; ISO too large for sanboot fallback' };
return { ok: false, reason: "not a recognized network-bootable installer (no Windows or Linux boot files found) — this image can't be PXE-booted" };
}
return { ok: true, warn: 'no kernel detected — sanboot fallback may not work' };
}
@@ -533,6 +536,9 @@
style:'display:none', id:'file'});
const prog = el('div', {class:'progress', id:'prog'}, el('div', {class:'bar', id:'bar'}));
const upMsg = el('div', {class:'msg', id:'upmsg'});
// v0.5.8: cancel button — shown only while an upload is in flight.
const cancelUpload = el('button', {class:'danger', type:'button',
style:'display:none;margin-top:12px', id:'cancel-upload'}, 'Cancel upload');
drop.onclick = () => file.click();
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('hover'); });
@@ -575,6 +581,16 @@
};
let uploadId = null;
// v0.5.8: cancel + leave-page guard. The AbortController stops the
// in-flight chunk; the beforeunload listener warns the operator
// that navigating away aborts the upload (the server-side partial
// is then cleaned up by the DELETE in the catch below).
const ac = new AbortController();
let canceled = false;
const warnLeave = (e) => { e.preventDefault(); e.returnValue = ''; return ''; };
window.addEventListener('beforeunload', warnLeave);
cancelUpload.style.display = '';
cancelUpload.onclick = () => { canceled = true; ac.abort(); };
setStatus('Preparing upload for ' + f.name + ' (' + fmtBytes(f.size) + ')');
prog.classList.add('active');
bar.style.width = '1%';
@@ -601,6 +617,7 @@
'x-openpxe-upload-complete': complete ? 'true' : 'false',
},
body: f.slice(offset, end),
signal: ac.signal,
});
if (!r.ok) throw new Error(await failText(r));
const j = await r.json();
@@ -616,8 +633,15 @@
try { await fetch('/api/uploads/' + encodeURIComponent(uploadId), {method: 'DELETE'}); }
catch {}
}
setStatus('Upload failed: ' + (err && err.message ? err.message : String(err)), 'err');
if (canceled || (err && err.name === 'AbortError')) {
setStatus('Upload canceled — partial file discarded.', '');
} else {
setStatus('Upload failed: ' + (err && err.message ? err.message : String(err)), 'err');
}
} finally {
window.removeEventListener('beforeunload', warnLeave);
cancelUpload.style.display = 'none';
cancelUpload.onclick = null;
prog.classList.remove('active');
if (!upMsg.className.includes('ok')) bar.style.width = '0';
}
@@ -629,7 +653,11 @@
// *next* row of the table. Keeps the markup flat and avoids the
// overhead of a real modal.
const rowsAndEditors = [];
isos.forEach(i => {
// v0.5.8: list Available images alphabetically by filename
// (case-insensitive, natural numeric order) instead of newest-first.
const sortedIsos = [...isos].sort((a, b) =>
(a.filename || '').localeCompare(b.filename || '', undefined, { sensitivity: 'base', numeric: true }));
sortedIsos.forEach(i => {
const b = bootability(i, settings);
// v0.4.65: SMB userspace consumer (smbclient).
// v0.4.67: NFS back as in-process Rust client (nfs3_client).
@@ -1164,7 +1192,7 @@
diskCard,
el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Upload ISO')),
el('div', {class:'body'}, [drop, file, prog, upMsg]),
el('div', {class:'body'}, [drop, file, prog, upMsg, cancelUpload]),
]),
// v0.5.1: SMB + NFS unified into one "Remote shares" card with a
// protocol dropdown. Backend endpoints are unchanged; this is a
+21
View File
@@ -0,0 +1,21 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OpenPXE">
<title>OpenPXE</title>
<!-- Static README mark: the "rainbow-horizon" medallion from the web UI,
with the SMIL animation removed so it renders reliably as an <img>
on Gitea/GitHub. -->
<defs>
<linearGradient id="opxRainbow" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#330f1f"/>
<stop offset="12.56%" stop-color="#c83228"/>
<stop offset="25.06%" stop-color="#fb8841"/>
<stop offset="37.56%" stop-color="#d3dd92"/>
<stop offset="50.06%" stop-color="#59824f"/>
<stop offset="62.06%" stop-color="#002414"/>
<stop offset="74.06%" stop-color="#00143d"/>
<stop offset="86.06%" stop-color="#2874d7"/>
<stop offset="100%" stop-color="#99c2ff"/>
</linearGradient>
</defs>
<circle cx="12" cy="12" r="10.5" fill="url(#opxRainbow)"
stroke="rgba(0,0,0,0.18)" stroke-width="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 984 B