Compare commits

..
5 Commits
Author SHA1 Message Date
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
70 changed files with 1426 additions and 10677 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 -1
View File
@@ -1,4 +1,5 @@
/target
Cargo.lock
data/isos/*.iso
data/isos/*.partial
data/isos/*.meta.json
@@ -10,4 +11,3 @@ data/work/
# settings.local.json is your personal allowlist history and shouldn't be).
.claude/settings.local.json
.claude/worktrees/
.claude/scheduled_tasks.lock
Generated
-2619
View File
File diff suppressed because it is too large Load Diff
+12 -13
View File
@@ -8,16 +8,16 @@ members = [
"crates/iso-store",
"crates/ipxe-assets",
"crates/webui",
"crates/openpxe",
"crates/pxeforge",
]
[workspace.package]
version = "0.4.62"
version = "0.2.0"
edition = "2021"
rust-version = "1.95"
rust-version = "1.80"
license = "MIT OR Apache-2.0"
repository = "https://gitea.milesward.dev/mward4/OpenPXE"
authors = ["OpenPXE contributors"]
repository = "https://github.com/casperadmin/PXEForge"
authors = ["PXEForge contributors"]
[workspace.dependencies]
tokio = { version = "1.40", features = ["full"] }
@@ -53,18 +53,17 @@ 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"] }
openpxe-core = { path = "crates/core" }
openpxe-dhcp-proxy = { path = "crates/dhcp-proxy" }
openpxe-tftp = { path = "crates/tftp" }
openpxe-http-api = { path = "crates/http-api" }
openpxe-iso-store = { path = "crates/iso-store" }
openpxe-ipxe-assets = { path = "crates/ipxe-assets" }
openpxe-webui = { path = "crates/webui" }
pxeforge-core = { path = "crates/core" }
pxeforge-dhcp-proxy = { path = "crates/dhcp-proxy" }
pxeforge-tftp = { path = "crates/tftp" }
pxeforge-http-api = { path = "crates/http-api" }
pxeforge-iso-store = { path = "crates/iso-store" }
pxeforge-ipxe-assets = { path = "crates/ipxe-assets" }
pxeforge-webui = { path = "crates/webui" }
[workspace.lints.rust]
unsafe_code = "deny"
+59 -59
View File
@@ -1,16 +1,16 @@
# OpenPXE
# PXEForge
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.
> **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.
> **Status:** v0.2.0 / pre-beta. Phases 15 complete: full PXE stack,
> Gated 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 anvil
> imaging-progress widget. **66 tests passing**, clippy clean. Ready
> for real-hardware validation.
## Design non-negotiables
@@ -38,26 +38,27 @@ clients PXE-boot them.
```
Default > Boot from Local HDD
Installers > Linux Installers / Windows Installers
Tools > Utilities / OpenPXE Shell / Network Card Info
Queued Deployment
Tools > Utilities / PXEForge Shell / Network Card Info
Gated Deployment
```
6. **Queued Deployment queue** — the coordinated launch flow. A client that
selects *Queued Deployment* gets a numbered position and waits. The
6. **Gated Deployment queue** — the "horse race gate" flow. A client that
selects *Gated 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
skips the menu, chains straight through.
skips the menu, chains straight through. Inspired by Tinkerbell's
`smee` MAC-prepended URL pattern.
9. **Prometheus metrics** at `/metrics` — DHCP replies by arch, TFTP
transfer counts and bytes, HTTP request counts by route, queue /
transfer counts and bytes, HTTP request counts by route, gate /
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
600s), the timeout action (stay / Local HDD / Gated Deployment), and
feature toggles like Windows ISO support. The iPXE scripts regenerate
on every request using current settings.
@@ -80,17 +81,17 @@ 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.4.1 --load .
docker buildx build -f deploy/docker/Dockerfile -t pxeforge: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.
docker run -d --name openpxe \
docker run -d --name pxeforge \
--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
-e PXEFORGE_PUBLIC_IP=10.0.0.5 \
-e PXEFORGE_DHCP_MODE=proxy \
-v $PWD/data/isos:/var/lib/pxeforge/isos \
-v $PWD/data/work:/var/lib/pxeforge/work \
pxeforge:0.1.0
# 4. Open the UI and drop an ISO in.
open http://10.0.0.5
@@ -98,16 +99,16 @@ 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
VM, so "host" means the VM — use `pxeforge-dev` in `docker-compose.yml` for
API-only testing on a laptop.
### Quick start — docker compose
```bash
# MVP / API testing on a laptop (no DHCP, high ports):
OPENPXE_PUBLIC_IP=127.0.0.1 docker compose up openpxe-dev
PXEFORGE_PUBLIC_IP=127.0.0.1 docker compose up pxeforge-dev
# Real PXE deployment on a Linux host (host network, DHCP proxy on):
OPENPXE_PUBLIC_IP=10.0.0.5 docker compose up openpxe
PXEFORGE_PUBLIC_IP=10.0.0.5 docker compose up pxeforge
```
### Multi-arch build + push
@@ -116,12 +117,12 @@ 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
docker buildx create --name pxeforge-multi --driver docker-container --use
# Build + push both linux/amd64 and linux/arm64 under one tag.
docker buildx build --builder openpxe-multi \
docker buildx build --builder pxeforge-multi \
--platform linux/amd64,linux/arm64 \
-t ghcr.io/YOUR-ORG/openpxe:0.4.1 \
-t ghcr.io/YOUR-ORG/pxeforge:0.1.0 \
--push \
-f deploy/docker/Dockerfile .
```
@@ -152,31 +153,31 @@ same pipeline the web UI uses (introspection + boot-entry generation):
```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
-v pxeforge-data:/var/lib/pxeforge/isos \
-e PXEFORGE_PUBLIC_IP=10.0.0.5 \
pxeforge: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.4.1 seed --from /seed --dry-run
docker run --rm -v /my/iso-library:/seed:ro pxeforge:0.1.0 seed --from /seed --dry-run
```
### Environment overrides
| 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 |
| `PXEFORGE_HTTP_PORT` | `80` | Web UI + boot script HTTP port |
| `PXEFORGE_TFTP_PORT` | `69` | TFTP port |
| `PXEFORGE_DHCP_PORT` | `67` | DHCP server-side port |
| `PXEFORGE_DHCP_MODE` | `proxy` | `proxy` or `disabled` |
| `PXEFORGE_PUBLIC_IP` | auto-detect | Advertised IP for clients. Startup **fails** if unset and auto-detect returns loopback. |
| `PXEFORGE_ISO_DIR` | `/var/lib/pxeforge/isos` | Where uploaded ISOs live |
| `PXEFORGE_WORK_DIR` | `/var/lib/pxeforge/work` | Scratch + runtime settings |
| `PXEFORGE_LOG` | `info,pxeforge=debug` | `tracing` filter |
## What the boot menu looks like on a real client
```
OpenPXE - network boot menu
PXEForge - network boot menu
------------------------- Default -------------------------
Boot from Local HDD
@@ -187,14 +188,14 @@ docker run --rm -v /my/iso-library:/seed:ro openpxe:0.4.1 seed --from /seed --dr
Tools > Utilities / Shell /
NIC Info / Reboot /
Exit and continue BIOS
---------------------- Queued Deployment ------------------
Queued Deployment (join queue)
---------------------- Gated Deployment ------------------
Gated Deployment (join queue)
```
Linux/Windows submenus show file sizes iVentoy-style:
```
OpenPXE - Linux Installers
PXEForge - Linux Installers
[ 4376 MB] CentOS-7-x86_64-DVD-1810
[ 2002 MB] Fedora-Workstation-Live-x86_64-38-1.6
@@ -209,8 +210,8 @@ ISOs you upload via drag-and-drop in the web UI plus toggles in Settings.
```bash
oc apply -f deploy/openshift/
oc -n openpxe get all
oc -n openpxe get route openpxe -o jsonpath='{.spec.host}'
oc -n pxeforge get all
oc -n pxeforge get route pxeforge -o jsonpath='{.spec.host}'
```
### Why a custom SCC?
@@ -218,7 +219,7 @@ oc -n openpxe get route openpxe -o jsonpath='{.spec.host}'
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,
`pxeforge-scc` grants exactly those two and nothing else. No raw sockets,
no privileged mode — proxy-mode DHCP sidesteps the usual requirements.
### What's on host ports
@@ -238,7 +239,7 @@ the node's host IP directly for UDP.
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
2. On upload, PXEForge 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`.
@@ -270,23 +271,22 @@ operational constraints inherited from the design:
- Hardware with NICs/storage controllers missing from WinPE's bundled
drivers will need a driver-pack injection step (not yet implemented).
## Queued Deployment
## Gated Deployment
The coordinated launch flow, end to end:
The "horse race gate" flow, end to end:
1. A client boots and picks **Queued Deployment** in the PXE menu (or falls
1. A client boots and picks **Gated 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
2. The client joins the queue, gets a numbered gate 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
3. In the web UI's **Gated 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
The server broadcasts the assignment to every gated 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
gate opens 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.
+2 -5
View File
@@ -1,10 +1,10 @@
[package]
name = "openpxe-core"
name = "pxeforge-core"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "Shared types, config, and arch detection for OpenPXE"
description = "Shared types, config, and arch detection for PXEForge"
[lints]
workspace = true
@@ -21,9 +21,6 @@ time.workspace = true
uuid.workspace = true
parking_lot.workspace = true
tokio = { workspace = true, features = ["sync", "rt", "macros", "time"] }
# bcrypt for the admin Forms auth (v0.4.5). Already in the workspace
# for per-ISO boot passwords; just re-exported here.
bcrypt.workspace = true
[dev-dependencies]
tempfile = "3.12"
+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);
}
-353
View File
@@ -1,353 +0,0 @@
//! Operator authentication — Sonarr/Radarr-style single-admin Forms model.
//!
//! On a fresh install, no admin account exists; the WebUI's first-run
//! flow prompts the operator to create one. After that the chosen
//! credentials gate `/api/*` access. The admin can rotate username +
//! password from Settings → Account.
//!
//! Multi-user RBAC isn't a goal for OpenPXE — the user explicitly asked
//! for "you have access or you don't". When SSO is configured, additional
//! users come in through the IdP; the locally-stored admin is the
//! fallback owner who can change SSO config or the seal-breaker for an
//! IdP outage. So one record is enough.
//!
//! Storage policy mirrors [`crate::host_bindings::HostBindings`] and
//! [`crate::boot_log::BootLog`]: in-memory authoritative; disk is the
//! crash-survival cache; a corrupt `auth.json` falls back to "no admin
//! configured" rather than blocking startup, which puts the UI back
//! into setup mode rather than locking the operator out.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use time::OffsetDateTime;
use crate::{Error, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdminAccount {
pub username: String,
/// bcrypt hash (cost 10). The plaintext password never leaves the
/// request that set it — same discipline as the per-ISO boot password.
pub password_hash: String,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
}
/// Public projection — no hash, safe to ship to the WebUI.
#[derive(Debug, Clone, Serialize)]
pub struct AdminPublic {
pub username: String,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
}
impl From<&AdminAccount> for AdminPublic {
fn from(a: &AdminAccount) -> Self {
Self {
username: a.username.clone(),
created_at: a.created_at,
updated_at: a.updated_at,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Inner {
admin: Option<AdminAccount>,
}
/// In-memory + on-disk admin registry. Cheap to clone.
#[derive(Debug, Clone)]
pub struct AdminStore {
path: Arc<PathBuf>,
inner: Arc<RwLock<Inner>>,
}
impl AdminStore {
/// Load from `<work_dir>/auth.json`, or start empty. A bad file
/// logs a warning and falls back to "no admin configured" — better
/// to surface the setup flow than lock the operator out of their
/// own install.
#[must_use]
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("auth.json");
let inner = match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str::<Inner>(&text) {
Ok(parsed) => parsed,
Err(e) => {
tracing::warn!(
target: "openpxe::auth",
"auth.json present but unreadable ({e}); starting in setup mode"
);
Inner::default()
}
},
Err(_) => Inner::default(),
};
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(inner)),
}
}
/// Has an admin been bootstrapped? Drives the first-run / login
/// fork in the HTTP layer.
#[must_use]
pub fn is_configured(&self) -> bool {
self.inner.read().admin.is_some()
}
/// Public-safe snapshot for the WebUI.
#[must_use]
pub fn snapshot(&self) -> Option<AdminPublic> {
self.inner.read().admin.as_ref().map(AdminPublic::from)
}
/// First-run setup: create the admin account. Fails if one already
/// exists — the HTTP layer surfaces that as 409.
pub fn bootstrap(&self, username: &str, password: &str) -> Result<AdminPublic> {
validate_username(username)?;
validate_password(password)?;
let hash = bcrypt_hash(password)?;
let now = OffsetDateTime::now_utc();
let admin = AdminAccount {
username: username.trim().to_string(),
password_hash: hash,
created_at: now,
updated_at: now,
};
{
let mut g = self.inner.write();
if g.admin.is_some() {
return Err(Error::Invalid(
"admin account already configured".into(),
));
}
g.admin = Some(admin.clone());
}
self.persist();
tracing::info!(
target: "openpxe::auth",
username = %admin.username,
"admin account created (first-run setup)"
);
Ok((&admin).into())
}
/// Verify credentials. Returns the admin record (public projection)
/// on success, `Ok(None)` on mismatch, `Err` on systemic bcrypt
/// failure (treated as "auth not available right now" by callers).
pub fn verify(&self, username: &str, password: &str) -> Result<Option<AdminPublic>> {
let Some(admin) = self.inner.read().admin.clone() else {
return Ok(None);
};
if username.trim() != admin.username {
return Ok(None);
}
// bcrypt compares in constant time relative to the same hash.
// Doing the username check first is fine — a username mismatch
// returns immediately, but the only thing leaked is "this isn't
// the admin's username" which the operator already knows.
match bcrypt::verify(password, &admin.password_hash) {
Ok(true) => Ok(Some((&admin).into())),
Ok(false) => Ok(None),
Err(e) => Err(Error::Other(e.into())),
}
}
/// Rotate username and/or password. `current_password` must match
/// the *existing* hash — same flow as Sonarr's "current password
/// required to change". `new_username`/`new_password` are optional:
/// pass only what you want to change.
pub fn update_credentials(
&self,
current_password: &str,
new_username: Option<&str>,
new_password: Option<&str>,
) -> Result<AdminPublic> {
// Re-check ownership before any state mutation.
let existing = self
.inner
.read()
.admin
.clone()
.ok_or_else(|| Error::Invalid("no admin configured".into()))?;
match bcrypt::verify(current_password, &existing.password_hash) {
Ok(true) => {}
Ok(false) => return Err(Error::Invalid("current password is incorrect".into())),
Err(e) => return Err(Error::Other(e.into())),
}
let mut updated = existing.clone();
if let Some(u) = new_username {
validate_username(u)?;
updated.username = u.trim().to_string();
}
if let Some(p) = new_password {
validate_password(p)?;
updated.password_hash = bcrypt_hash(p)?;
}
updated.updated_at = OffsetDateTime::now_utc();
{
let mut g = self.inner.write();
g.admin = Some(updated.clone());
}
self.persist();
tracing::info!(
target: "openpxe::auth",
username = %updated.username,
"admin credentials updated"
);
Ok((&updated).into())
}
fn persist(&self) {
let snap = self.inner.read().clone();
let body = match serde_json::to_vec_pretty(&snap) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::auth", "serialize auth.json: {e}");
return;
}
};
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = self.path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::auth", "write auth.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::warn!(target: "openpxe::auth", "rename auth.json: {e}");
}
}
}
fn validate_username(u: &str) -> Result<()> {
let u = u.trim();
if u.is_empty() {
return Err(Error::Invalid("username must not be empty".into()));
}
if u.len() > 64 {
return Err(Error::Invalid("username must be 64 chars or fewer".into()));
}
if !u.chars().all(|c| c.is_ascii_graphic() && c != ':') {
return Err(Error::Invalid(
"username must be ASCII printable with no ':' character".into(),
));
}
Ok(())
}
fn validate_password(p: &str) -> Result<()> {
if p.len() < 8 {
return Err(Error::Invalid(
"password must be at least 8 characters".into(),
));
}
if p.len() > 256 {
return Err(Error::Invalid(
"password must be 256 characters or fewer".into(),
));
}
Ok(())
}
fn bcrypt_hash(password: &str) -> Result<String> {
bcrypt::hash(password, bcrypt::DEFAULT_COST).map_err(|e| Error::Other(e.into()))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn empty_after_load_when_no_file() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
assert!(!s.is_configured());
assert!(s.snapshot().is_none());
}
#[test]
fn bootstrap_then_verify_round_trip() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
let pub_ = s.bootstrap("admin", "hunter2hunter2").unwrap();
assert_eq!(pub_.username, "admin");
assert!(s.is_configured());
// Correct creds match; wrong creds don't.
assert!(s.verify("admin", "hunter2hunter2").unwrap().is_some());
assert!(s.verify("admin", "wrong").unwrap().is_none());
assert!(s.verify("nobody", "hunter2hunter2").unwrap().is_none());
}
#[test]
fn bootstrap_rejects_second_call() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
s.bootstrap("admin", "hunter2hunter2").unwrap();
let r = s.bootstrap("other", "anotherpass1");
assert!(matches!(r, Err(Error::Invalid(_))));
}
#[test]
fn round_trip_survives_disk_reload() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
s.bootstrap("admin", "hunter2hunter2").unwrap();
drop(s);
let s2 = AdminStore::load_or_default(dir.path());
assert!(s2.is_configured());
assert!(s2.verify("admin", "hunter2hunter2").unwrap().is_some());
}
#[test]
fn update_credentials_requires_current_password() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
s.bootstrap("admin", "hunter2hunter2").unwrap();
// Wrong current password → no change.
let r = s.update_credentials("nope", None, Some("newpassword1"));
assert!(matches!(r, Err(Error::Invalid(_))));
assert!(s.verify("admin", "hunter2hunter2").unwrap().is_some());
// Correct current password rotates only what's supplied.
s.update_credentials("hunter2hunter2", Some("alice"), Some("newpassword1"))
.unwrap();
assert!(s.verify("admin", "hunter2hunter2").unwrap().is_none());
assert!(s.verify("alice", "newpassword1").unwrap().is_some());
}
#[test]
fn update_credentials_partial_password_only_keeps_username() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
s.bootstrap("admin", "hunter2hunter2").unwrap();
s.update_credentials("hunter2hunter2", None, Some("newpassword1"))
.unwrap();
assert!(s.verify("admin", "newpassword1").unwrap().is_some());
}
#[test]
fn validates_username_and_password() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
assert!(s.bootstrap("", "hunter2hunter2").is_err());
assert!(s.bootstrap("ad:min", "hunter2hunter2").is_err()); // ':' reserved
assert!(s.bootstrap("admin", "short").is_err()); // <8 chars
// 65-char username is too long.
let long = "a".repeat(65);
assert!(s.bootstrap(&long, "hunter2hunter2").is_err());
}
}
-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);
}
}
-339
View File
@@ -1,339 +0,0 @@
//! Operator-controlled branding overrides.
//!
//! The browser tab's logo (`/assets/logo.svg`) defaults to the bundled
//! rainbow-horizon mark. Operators who deploy OpenPXE behind their own
//! branding can upload a replacement that lives at
//! `<work_dir>/branding/logo.<ext>` and is served in preference to the
//! bundled SVG when present. Borrowed-from-FleetDM: tenant chrome, same
//! product.
//!
//! Storage policy mirrors `HostBindings` / `BootLog`: in-memory cache is
//! authoritative for the current process, disk is the source of truth on
//! restart, and a corrupt cache file falls back to the bundled default
//! rather than blocking startup.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Allowed MIME types for an uploaded logo. We deliberately keep this
/// narrow — anything that can be `<img src="...">`'d into the brand
/// block, no scripts. SVG carries the obvious XSS risk for raw inline
/// HTML; we always serve the bytes as a separate asset with a strict
/// content-type rather than inlining, so SVG is safe.
pub const ALLOWED_LOGO_MIMES: &[&str] = &[
"image/svg+xml",
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
];
/// Disk cap for an uploaded logo. PXE WebUIs are operator-facing — even
/// a generous 2 MB cap is comfortable for any reasonable brand mark and
/// puts a clear bound on memory + serialization cost.
pub const MAX_LOGO_BYTES: usize = 2 * 1024 * 1024;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Inner {
/// File name (relative to the branding dir) for the active logo, if
/// any. Always under `<work_dir>/branding/`; never an absolute path
/// from the operator.
logo_filename: Option<String>,
/// MIME of the active logo, mirroring `logo_filename`. Cached here
/// so the HTTP layer can set Content-Type without re-sniffing.
logo_mime: Option<String>,
}
/// In-memory + on-disk override registry. Cheap to clone; locks are
/// brief. The `branding.json` cache lives alongside the active asset
/// inside `<work_dir>/branding/`.
#[derive(Debug, Clone)]
pub struct BrandingStore {
/// Root directory: `<work_dir>/branding/`. Created on first write.
dir: Arc<PathBuf>,
inner: Arc<RwLock<Inner>>,
}
impl BrandingStore {
/// Load (or initialise empty) from `<work_dir>/branding/`. Tolerates
/// missing directories, partial state, and corrupt JSON — a bad
/// cache should never block PXE for the network.
#[must_use]
pub fn load_or_default(work_dir: &Path) -> Self {
let dir = work_dir.join("branding");
let path = dir.join("branding.json");
let mut inner = Inner::default();
if let Ok(text) = std::fs::read_to_string(&path) {
match serde_json::from_str::<Inner>(&text) {
Ok(parsed) => {
// Sanity: if the JSON says we have a logo but the
// file is gone, clear the in-memory pointer so
// /assets/logo.svg falls back to the bundled SVG
// rather than 500ing on a missing file.
if let Some(name) = parsed.logo_filename.as_deref() {
if dir.join(name).is_file() {
inner = parsed;
} else {
tracing::warn!(
target: "openpxe::branding",
file = %name,
"branding.json points at missing file; clearing"
);
}
} else {
inner = parsed;
}
}
Err(e) => {
tracing::warn!(
target: "openpxe::branding",
"branding.json present but unreadable ({e}); starting empty"
);
}
}
}
Self {
dir: Arc::new(dir),
inner: Arc::new(RwLock::new(inner)),
}
}
/// Absolute path to the active logo, if one is set and present on
/// disk. `None` means the HTTP layer should serve the bundled SVG.
#[must_use]
pub fn logo_path(&self) -> Option<PathBuf> {
let g = self.inner.read();
g.logo_filename.as_deref().map(|n| self.dir.join(n))
}
/// MIME of the active logo, if any. The HTTP layer pairs this with
/// the bytes returned by [`Self::logo_path`].
#[must_use]
pub fn logo_mime(&self) -> Option<String> {
self.inner.read().logo_mime.clone()
}
/// Replace the active logo. Returns the chosen on-disk filename so
/// the caller can echo it back in the API response. Old logos are
/// removed best-effort.
pub fn set_logo(&self, mime: &str, ext: &str, bytes: &[u8]) -> std::io::Result<String> {
std::fs::create_dir_all(self.dir.as_path())?;
// Single canonical filename per upload — overwriting the old one
// (after clearing it) keeps the directory tidy and avoids any
// path-traversal concern: the operator never supplies the name.
let safe_ext = sanitize_ext(ext);
let filename = format!("logo.{safe_ext}");
let final_path = self.dir.join(&filename);
// Atomic write: tmp -> rename. Guarantees the file is either
// entirely the old logo or entirely the new one.
let tmp = final_path.with_extension(format!("{safe_ext}.tmp"));
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, &final_path)?;
// Clean up any sibling logo.<otherext> so there's exactly one
// canonical file at any time.
if let Ok(entries) = std::fs::read_dir(self.dir.as_path()) {
for e in entries.flatten() {
let p = e.path();
let name = p
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
if name.starts_with("logo.") && name != filename {
let _ = std::fs::remove_file(&p);
}
}
}
{
let mut g = self.inner.write();
g.logo_filename = Some(filename.clone());
g.logo_mime = Some(mime.to_string());
}
self.persist();
tracing::info!(
target: "openpxe::branding",
file = %filename, mime = %mime, size = bytes.len(),
"custom logo installed"
);
Ok(filename)
}
/// Drop the override and return to the bundled SVG.
pub fn clear_logo(&self) -> std::io::Result<()> {
let removed = {
let mut g = self.inner.write();
let removed = g.logo_filename.take();
g.logo_mime = None;
removed
};
if let Some(name) = removed {
let p = self.dir.join(&name);
let _ = std::fs::remove_file(&p);
tracing::info!(target: "openpxe::branding", file = %name, "custom logo cleared");
}
self.persist();
Ok(())
}
/// Convenience: true if a custom logo is configured. Surfaces on
/// `/api/status` so the WebUI can show "Custom logo: yes" without
/// fetching the asset itself.
#[must_use]
pub fn has_logo(&self) -> bool {
self.inner.read().logo_filename.is_some()
}
fn persist(&self) {
let snap = self.inner.read().clone();
let body = match serde_json::to_vec_pretty(&snap) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::branding", "serialize branding.json: {e}");
return;
}
};
if let Err(e) = std::fs::create_dir_all(self.dir.as_path()) {
tracing::warn!(target: "openpxe::branding", "mkdir branding/: {e}");
return;
}
let path = self.dir.join("branding.json");
let tmp = path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::branding", "write branding.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, &path) {
tracing::warn!(target: "openpxe::branding", "rename branding.json: {e}");
}
}
}
/// Trim arbitrary operator-supplied extension strings to a small, safe
/// alphanumeric form. Anything weird collapses to `bin`. We never let
/// the extension affect the path beyond the final segment of `logo.<x>`.
fn sanitize_ext(ext: &str) -> String {
let lc: String = ext
.chars()
.filter(char::is_ascii_alphanumeric)
.map(|c| c.to_ascii_lowercase())
.collect();
if lc.is_empty() || lc.len() > 5 {
"bin".into()
} else {
lc
}
}
/// Pick a safe filesystem extension from a MIME type. Returns `None`
/// if the MIME isn't on the [`ALLOWED_LOGO_MIMES`] allowlist.
#[must_use]
pub fn ext_for_mime(mime: &str) -> Option<&'static str> {
match mime {
"image/svg+xml" => Some("svg"),
"image/png" => Some("png"),
"image/jpeg" => Some("jpg"),
"image/webp" => Some("webp"),
"image/gif" => Some("gif"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn empty_after_load_when_no_branding_dir() {
let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path());
assert!(!b.has_logo());
assert!(b.logo_path().is_none());
assert!(b.logo_mime().is_none());
}
#[test]
fn set_clear_round_trip_persists() {
let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path());
let name = b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake").unwrap();
assert_eq!(name, "logo.png");
assert!(b.has_logo());
assert_eq!(b.logo_mime().as_deref(), Some("image/png"));
let p = b.logo_path().unwrap();
assert!(p.is_file());
// Re-open and confirm the override survives a restart.
drop(b);
let b2 = BrandingStore::load_or_default(dir.path());
assert!(b2.has_logo());
assert_eq!(b2.logo_mime().as_deref(), Some("image/png"));
// Clear; the file goes away and has_logo flips off.
b2.clear_logo().unwrap();
assert!(!b2.has_logo());
assert!(!p.exists());
}
#[test]
fn replacing_logo_removes_old_extension_sibling() {
// PNG then SVG; only the SVG should remain on disk.
let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path());
b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake").unwrap();
b.set_logo("image/svg+xml", "svg", br#"<svg xmlns="http://www.w3.org/2000/svg"/>"#).unwrap();
let entries: Vec<_> = std::fs::read_dir(dir.path().join("branding"))
.unwrap()
.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned()))
.collect();
assert!(entries.iter().any(|n| n == "logo.svg"), "got {entries:?}");
assert!(!entries.iter().any(|n| n == "logo.png"), "stale PNG left over: {entries:?}");
}
#[test]
fn sanitize_ext_strips_separators_and_path_chars() {
assert_eq!(sanitize_ext("svg"), "svg");
// Path separators and non-alphanumerics filter out, leaving just
// letters. The remaining "etcpasswd" exceeds the 5-char cap so
// it collapses to `bin` rather than producing `etcpa`.
assert_eq!(sanitize_ext("../etc/passwd"), "bin");
// Short alphanumeric strip-through stays itself.
assert_eq!(sanitize_ext("../svg"), "svg");
assert_eq!(sanitize_ext(""), "bin");
assert_eq!(sanitize_ext("PNG"), "png");
// Anything past five chars is suspicious — collapse to `bin`.
assert_eq!(sanitize_ext("svgvvvv"), "bin");
}
#[test]
fn missing_file_referenced_by_json_resolves_to_empty() {
// If the operator nukes the file out from under the JSON cache,
// we should silently fall back to no-override rather than
// hanging on to a bogus path.
let dir = tempdir().unwrap();
let brand_dir = dir.path().join("branding");
std::fs::create_dir_all(&brand_dir).unwrap();
// Hand-write a branding.json claiming logo.png exists.
let inner = Inner {
logo_filename: Some("logo.png".into()),
logo_mime: Some("image/png".into()),
};
std::fs::write(
brand_dir.join("branding.json"),
serde_json::to_vec_pretty(&inner).unwrap(),
)
.unwrap();
let b = BrandingStore::load_or_default(dir.path());
assert!(!b.has_logo(), "should fall back when referenced file is missing");
}
#[test]
fn ext_for_mime_only_accepts_known_types() {
assert_eq!(ext_for_mime("image/png"), Some("png"));
assert_eq!(ext_for_mime("image/svg+xml"), Some("svg"));
assert_eq!(ext_for_mime("application/octet-stream"), None);
assert_eq!(ext_for_mime("text/html"), None);
}
}
+12 -18
View File
@@ -56,25 +56,19 @@ impl ClientRegistry {
) {
let mut guard = self.inner.write();
let now = OffsetDateTime::now_utc();
let entry = guard
.entry(mac.to_string())
.or_insert_with(|| ClientSnapshot {
mac: mac.to_string(),
last_ip: ip,
arch,
hostname: None,
first_seen: now,
last_seen: now,
events: Vec::new(),
selected_target: None,
});
let entry = guard.entry(mac.to_string()).or_insert_with(|| ClientSnapshot {
mac: mac.to_string(),
last_ip: ip,
arch,
hostname: None,
first_seen: now,
last_seen: now,
events: Vec::new(),
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;
+21 -29
View File
@@ -54,7 +54,7 @@ pub enum DhcpMode {
#[default]
Proxy,
/// Disabled — rely on an external DHCP server that has been manually
/// configured with `next-server` / `filename`. OpenPXE only serves TFTP
/// configured with `next-server` / `filename`. PXEForge only serves TFTP
/// + HTTP in this mode. Useful for home routers that can be pre-set.
Disabled,
}
@@ -68,11 +68,11 @@ 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
/// `/var/lib/openpxe/smb` in the container image.
/// `/var/lib/pxeforge/smb` in the container image.
pub smb_dir: PathBuf,
}
@@ -104,11 +104,11 @@ impl Default for NetworkConfig {
impl Default for Paths {
fn default() -> Self {
Self {
iso_dir: PathBuf::from("/var/lib/openpxe/isos"),
work_dir: PathBuf::from("/var/lib/openpxe/work"),
ipxe_dir: PathBuf::from("/usr/share/openpxe/ipxe"),
iso_dir: PathBuf::from("/var/lib/pxeforge/isos"),
work_dir: PathBuf::from("/var/lib/pxeforge/work"),
ipxe_dir: PathBuf::from("/usr/share/pxeforge/ipxe"),
wimboot_path: None,
smb_dir: PathBuf::from("/var/lib/openpxe/smb"),
smb_dir: PathBuf::from("/var/lib/pxeforge/smb"),
}
}
}
@@ -124,46 +124,38 @@ impl Config {
}
/// Apply environment variable overrides. Env var names follow the pattern
/// `OPENPXE_<SECTION>_<FIELD>`, uppercase. Unknown vars are ignored.
/// `PXEFORGE_<SECTION>_<FIELD>`, uppercase. Unknown vars are ignored.
/// 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(v) = std::env::var("PXEFORGE_HTTP_PORT") {
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(v) = std::env::var("PXEFORGE_TFTP_PORT") {
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(v) = std::env::var("PXEFORGE_DHCP_PORT") {
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(v) = std::env::var("PXEFORGE_PUBLIC_IP") {
if let Ok(ip) = v.parse() { self.server.public_ip = Some(ip); }
}
if let Ok(v) = std::env::var("OPENPXE_DHCP_MODE") {
if let Ok(v) = std::env::var("PXEFORGE_DHCP_MODE") {
self.network.dhcp_mode = match v.to_ascii_lowercase().as_str() {
"proxy" => DhcpMode::Proxy,
"disabled" | "off" | "none" => DhcpMode::Disabled,
_ => self.network.dhcp_mode,
};
}
if let Ok(v) = std::env::var("OPENPXE_ISO_DIR") {
if let Ok(v) = std::env::var("PXEFORGE_ISO_DIR") {
self.paths.iso_dir = PathBuf::from(v);
}
if let Ok(v) = std::env::var("OPENPXE_WORK_DIR") {
if let Ok(v) = std::env::var("PXEFORGE_WORK_DIR") {
self.paths.work_dir = PathBuf::from(v);
}
if let Ok(v) = std::env::var("OPENPXE_IPXE_DIR") {
if let Ok(v) = std::env::var("PXEFORGE_IPXE_DIR") {
self.paths.ipxe_dir = PathBuf::from(v);
}
if let Ok(v) = std::env::var("OPENPXE_SMB_DIR") {
if let Ok(v) = std::env::var("PXEFORGE_SMB_DIR") {
self.paths.smb_dir = PathBuf::from(v);
}
}
@@ -1,16 +1,16 @@
//! Queued Deployment queue.
//! Gated 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
//! loop hitting `/api/queue/poll/<id>`; the server holds the request open
//! When a client selects "Gated Deployment" at the PXE menu, iPXE POSTs to
//! `/api/gate/join` and receives a gate position. It then enters a poll
//! loop hitting `/api/gate/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
//! `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.
//! The WebUI shows the queue (`GET /api/gate`) and issues
//! `POST /api/gate/assign { iso_id, gate_ids: [...] }` to launch a single
//! 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>,
@@ -40,7 +40,7 @@ pub struct QueueEntry {
}
#[derive(Debug)]
struct QueueEntryInner {
struct GateInner {
id: String,
position: u32,
mac: String,
@@ -54,9 +54,9 @@ struct QueueEntryInner {
notify: Arc<Notify>,
}
impl QueueEntryInner {
fn snapshot(&self) -> QueueEntry {
QueueEntry {
impl GateInner {
fn snapshot(&self) -> Gate {
Gate {
id: self.id.clone(),
position: self.position,
mac: self.mac.clone(),
@@ -70,38 +70,34 @@ impl QueueEntryInner {
}
#[derive(Debug, Default)]
pub struct DeploymentQueue {
inner: RwLock<HashMap<String, QueueEntryInner>>,
pub struct GateQueue {
inner: RwLock<HashMap<String, GateInner>>,
}
impl DeploymentQueue {
impl GateQueue {
#[must_use]
pub fn new() -> Arc<Self> {
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 {
let inner = GateInner {
id: id.clone(),
position: next_pos,
mac: mac.to_string(),
@@ -117,29 +113,29 @@ 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())
pub fn notifier(&self, gate_id: &str) -> Option<Arc<Notify>> {
self.inner.read().get(gate_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, gate_id: &str) -> Option<Gate> {
let mut guard = self.inner.write();
let g = guard.get_mut(entry_id)?;
let g = guard.get_mut(gate_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 {
pub fn assign(&self, gate_ids: &[String], target: &str) -> usize {
let mut guard = self.inner.write();
let mut updated = 0;
for id in entry_ids {
for id in gate_ids {
if let Some(g) = guard.get_mut(id) {
g.assigned_target = Some(target.to_string());
g.notify.notify_waiters();
@@ -149,11 +145,11 @@ 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, gate_id: &str) -> Option<Gate> {
let mut guard = self.inner.write();
let g = guard.remove(entry_id)?;
let g = guard.remove(gate_id)?;
g.notify.notify_waiters();
// Renumber positions so the display stays contiguous (1..N). This
// is O(N) but the queue is expected to be small (dozens of hosts).
@@ -166,9 +162,9 @@ 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();
let mut v: Vec<_> = guard.values().map(GateInner::snapshot).collect();
v.sort_by_key(|g| g.position);
v
}
@@ -190,7 +186,7 @@ mod tests {
#[test]
fn join_assigns_sequential_positions() {
let q = DeploymentQueue::new();
let q = GateQueue::new();
let g1 = q.join("aa:bb:cc:00:00:01", None, None);
let g2 = q.join("aa:bb:cc:00:00:02", None, None);
let g3 = q.join("aa:bb:cc:00:00:03", None, None);
@@ -201,7 +197,7 @@ mod tests {
#[test]
fn rejoining_same_mac_is_idempotent() {
let q = DeploymentQueue::new();
let q = GateQueue::new();
let g1 = q.join("aa:bb:cc:00:00:01", None, None);
let g2 = q.join("aa:bb:cc:00:00:01", None, None);
assert_eq!(g1.id, g2.id);
@@ -211,7 +207,7 @@ mod tests {
#[test]
fn assign_broadcasts_target() {
let q = DeploymentQueue::new();
let q = GateQueue::new();
let g1 = q.join("aa:bb:cc:00:00:01", None, None);
let g2 = q.join("aa:bb:cc:00:00:02", None, None);
let n = q.assign(&[g1.id.clone(), g2.id.clone()], "ubuntu-24-04-linux");
@@ -223,7 +219,7 @@ mod tests {
#[test]
fn release_renumbers() {
let q = DeploymentQueue::new();
let q = GateQueue::new();
let a = q.join("aa:00:00:00:00:01", None, None);
let _b = q.join("aa:00:00:00:00:02", None, None);
let c = q.join("aa:00:00:00:00:03", None, None);
@@ -238,7 +234,7 @@ mod tests {
#[tokio::test]
async fn assign_wakes_waiter() {
let q = DeploymentQueue::new();
let q = GateQueue::new();
let g = q.join("aa:00:00:00:00:01", None, None);
let notify = q.notifier(&g.id).unwrap();
+10 -9
View File
@@ -1,9 +1,10 @@
//! Per-MAC host bindings.
//!
//! Operators can attach a preferred boot target (a `BootEntry::id`) to a
//! specific MAC address. When a client with that MAC arrives, the top-level
//! boot script chains straight to that target instead of showing the
//! interactive menu.
//! Inspired by the Tinkerbell `smee` "MAC-prepended URL" pattern: an
//! operator can attach a preferred boot target (a `BootEntry::id`) to a
//! specific MAC address. When a client with that MAC arrives, the
//! top-level boot script chains straight to that target instead of
//! showing the interactive menu.
//!
//! Use cases:
//! - "This rack of Dell servers always images with Ubuntu Server 24.04"
@@ -28,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"`,
@@ -72,7 +73,7 @@ impl HostBindings {
}
Err(e) => {
tracing::warn!(
target: "openpxe::hosts",
target: "pxeforge::hosts",
"hosts.json present but unreadable ({e}); starting empty"
);
Inner::default()
@@ -148,7 +149,7 @@ impl HostBindings {
let body = match serde_json::to_vec_pretty(&items) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::hosts", "serialize hosts.json: {e}");
tracing::warn!(target: "pxeforge::hosts", "serialize hosts.json: {e}");
return;
}
};
@@ -157,11 +158,11 @@ impl HostBindings {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::hosts", "write hosts.json tmp: {e}");
tracing::warn!(target: "pxeforge::hosts", "write hosts.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::warn!(target: "openpxe::hosts", "rename hosts.json: {e}");
tracing::warn!(target: "pxeforge::hosts", "rename hosts.json: {e}");
}
}
}
+4 -12
View File
@@ -1,31 +1,23 @@
//! OpenPXE shared core: config, arch detection, client state registry,
//! runtime settings, and the Queued Deployment queue.
//! PXEForge shared core: config, arch detection, client state registry,
//! runtime settings, and the Gated Deployment queue.
#![forbid(unsafe_code)]
pub mod arch;
pub mod auth;
pub mod boot_log;
pub mod branding;
pub mod client;
pub mod config;
pub mod error;
pub mod gate;
pub mod host_bindings;
pub mod log_bus;
pub mod metrics;
pub mod queue;
pub mod settings;
pub mod sso;
pub use arch::{ClientArch, FirmwareClass};
pub use auth::{AdminAccount, AdminPublic, AdminStore};
pub use boot_log::{BootEvent, BootLog};
pub use branding::{ext_for_mime, BrandingStore, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES};
pub use client::{ClientEvent, ClientRegistry, ClientSnapshot};
pub use sso::{SsoConfig, SsoStore};
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
pub use error::{Error, Result};
pub use gate::{Gate, GateQueue};
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};
+1 -1
View File
@@ -38,7 +38,7 @@ impl LogLine {
/// Compact one-line "tail -f"-style render.
#[must_use]
pub fn render(&self) -> String {
// 2026-04-29T12:34:56Z [info] openpxe::http: HTTP listening on 0.0.0.0:80
// 2026-04-29T12:34:56Z [info] pxeforge::http: HTTP listening on 0.0.0.0:80
let ts = self
.timestamp
.format(&time::format_description::well_known::Rfc3339)
+52 -103
View File
@@ -1,21 +1,21 @@
//! Tiny lock-free Prometheus-compatible metrics.
//!
//! We don't pull in `prometheus` or `metrics-rs` for this — they bring
//! their own runtime, registry, and complexity. OpenPXE has a fixed,
//! their own runtime, registry, and complexity. PXEForge has a fixed,
//! tiny set of counters/gauges and the exposition format is plain text.
//! A handful of `AtomicU64`s and a `Display` impl gets us everything
//! Prometheus / Grafana / VictoriaMetrics needs to scrape:
//!
//! openpxe_dhcp_replies_total counter (per arch label)
//! openpxe_tftp_transfers_total counter (per status label)
//! openpxe_tftp_bytes_total counter
//! openpxe_http_requests_total counter (per route label)
//! openpxe_iso_count gauge
//! openpxe_client_count gauge
//! openpxe_queue_count gauge
//! openpxe_queue_imaging gauge
//! openpxe_uptime_seconds gauge
//! openpxe_build_info{version} gauge (always 1)
//! pxeforge_dhcp_replies_total counter (per arch label)
//! pxeforge_tftp_transfers_total counter (per status label)
//! pxeforge_tftp_bytes_total counter
//! pxeforge_http_requests_total counter (per route label)
//! pxeforge_iso_count gauge
//! pxeforge_client_count gauge
//! pxeforge_gate_count gauge
//! pxeforge_gate_imaging gauge
//! pxeforge_uptime_seconds gauge
//! pxeforge_build_info{version} gauge (always 1)
//!
//! Cheap to clone — internal state is a couple of arcs. Counters use
//! `Relaxed` ordering: we don't synchronise across counters, just need
@@ -47,8 +47,8 @@ struct Inner {
// Gauges (set explicitly; not cumulative)
iso_count: AtomicU64,
client_count: AtomicU64,
queue_count: AtomicU64,
queue_imaging: AtomicU64,
gate_count: AtomicU64,
gate_imaging: AtomicU64,
nfs_mounts_active: AtomicU64,
}
@@ -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 ───────────────────────────────────────────────────────────
@@ -115,9 +113,9 @@ impl Metrics {
self.inner.client_count.store(n, Ordering::Relaxed);
}
pub fn set_queue_counts(&self, total: u64, imaging: u64) {
self.inner.queue_count.store(total, Ordering::Relaxed);
self.inner.queue_imaging.store(imaging, Ordering::Relaxed);
pub fn set_gate_counts(&self, total: u64, imaging: u64) {
self.inner.gate_count.store(total, Ordering::Relaxed);
self.inner.gate_imaging.store(imaging, Ordering::Relaxed);
}
pub fn set_nfs_active(&self, n: u64) {
@@ -153,64 +151,58 @@ impl Metrics {
};
// Counters with one HELP/TYPE per metric name and per-label rows.
let _ = writeln!(out, "# HELP openpxe_dhcp_replies_total Number of proxyDHCP replies sent, by client architecture.");
let _ = writeln!(out, "# TYPE openpxe_dhcp_replies_total counter");
let _ = writeln!(out, "# HELP pxeforge_dhcp_replies_total Number of proxyDHCP replies sent, by client architecture.");
let _ = writeln!(out, "# TYPE pxeforge_dhcp_replies_total counter");
let _ = writeln!(
out,
"openpxe_dhcp_replies_total{{arch=\"bios\"}} {}",
"pxeforge_dhcp_replies_total{{arch=\"bios\"}} {}",
i.dhcp_replies_legacy.load(Ordering::Relaxed)
);
let _ = writeln!(
out,
"openpxe_dhcp_replies_total{{arch=\"uefi\"}} {}",
"pxeforge_dhcp_replies_total{{arch=\"uefi\"}} {}",
i.dhcp_replies_uefi.load(Ordering::Relaxed)
);
let _ = writeln!(
out,
"openpxe_dhcp_replies_total{{arch=\"arm64\"}} {}",
"pxeforge_dhcp_replies_total{{arch=\"arm64\"}} {}",
i.dhcp_replies_arm64.load(Ordering::Relaxed)
);
let _ = writeln!(
out,
"openpxe_dhcp_replies_total{{arch=\"unknown\"}} {}",
"pxeforge_dhcp_replies_total{{arch=\"unknown\"}} {}",
i.dhcp_replies_unknown.load(Ordering::Relaxed)
);
write_counter(
&mut out,
"openpxe_dhcp_declined_total",
"pxeforge_dhcp_declined_total",
"DHCP requests we saw but did not reply to (mac filter, arch unsupported, etc).",
i.dhcp_declined.load(Ordering::Relaxed),
"",
);
let _ = writeln!(out, "# HELP pxeforge_tftp_transfers_total TFTP transfers, by status.");
let _ = writeln!(out, "# TYPE pxeforge_tftp_transfers_total counter");
let _ = writeln!(
out,
"# HELP openpxe_tftp_transfers_total TFTP transfers, by status."
);
let _ = writeln!(out, "# TYPE openpxe_tftp_transfers_total counter");
let _ = writeln!(
out,
"openpxe_tftp_transfers_total{{status=\"ok\"}} {}",
"pxeforge_tftp_transfers_total{{status=\"ok\"}} {}",
i.tftp_transfers_ok.load(Ordering::Relaxed)
);
let _ = writeln!(
out,
"openpxe_tftp_transfers_total{{status=\"err\"}} {}",
"pxeforge_tftp_transfers_total{{status=\"err\"}} {}",
i.tftp_transfers_err.load(Ordering::Relaxed)
);
write_counter(
&mut out,
"openpxe_tftp_bytes_total",
"pxeforge_tftp_bytes_total",
"Total bytes successfully delivered over TFTP.",
i.tftp_bytes.load(Ordering::Relaxed),
"",
);
let _ = writeln!(
out,
"# HELP openpxe_http_requests_total HTTP requests served, by route family."
);
let _ = writeln!(out, "# TYPE openpxe_http_requests_total counter");
let _ = writeln!(out, "# HELP pxeforge_http_requests_total HTTP requests served, by route family.");
let _ = writeln!(out, "# TYPE pxeforge_http_requests_total counter");
for (label, counter) in [
("boot_script", &i.http_boot_script),
("iso_range", &i.http_iso_range),
@@ -220,61 +212,22 @@ impl Metrics {
] {
let _ = writeln!(
out,
"openpxe_http_requests_total{{route=\"{label}\"}} {}",
"pxeforge_http_requests_total{{route=\"{label}\"}} {}",
counter.load(Ordering::Relaxed)
);
}
// 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, "pxeforge_iso_count", "ISOs currently registered (local + NFS).", i.iso_count.load(Ordering::Relaxed), "");
write_gauge(&mut out, "pxeforge_client_count", "PXE clients seen this process lifetime.", i.client_count.load(Ordering::Relaxed), "");
write_gauge(&mut out, "pxeforge_gate_count", "Clients currently waiting at the deployment gate.", i.gate_count.load(Ordering::Relaxed), "");
write_gauge(&mut out, "pxeforge_gate_imaging", "Clients currently imaging (gate + assigned target).", i.gate_imaging.load(Ordering::Relaxed), "");
write_gauge(&mut out, "pxeforge_nfs_mounts_active", "NFS shares currently mounted.", i.nfs_mounts_active.load(Ordering::Relaxed), "");
write_gauge(&mut out, "pxeforge_uptime_seconds", "Seconds since this PXEForge instance started.", uptime_secs, "");
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");
let _ = writeln!(out, "# HELP pxeforge_build_info Build metadata. Always 1; the version is in the label.");
let _ = writeln!(out, "# TYPE pxeforge_build_info gauge");
let _ = writeln!(out, "pxeforge_build_info{{version=\"{version}\"}} 1");
out
}
@@ -306,20 +259,16 @@ 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_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"));
assert!(out.contains("openpxe_tftp_transfers_total{status=\"ok\"} 1"));
assert!(out.contains("openpxe_tftp_bytes_total 1024"));
assert!(out.contains("openpxe_http_requests_total{route=\"api\"} 1"));
assert!(out.contains("openpxe_iso_count 3"));
assert!(out.contains("openpxe_uptime_seconds 42"));
assert!(out.contains("openpxe_build_info{version=\"0.2.0\"} 1"));
assert_eq!(out.matches("# TYPE pxeforge_dhcp_replies_total counter").count(), 1);
assert_eq!(out.matches("# TYPE pxeforge_iso_count gauge").count(), 1);
assert!(out.contains("pxeforge_dhcp_replies_total{arch=\"uefi\"} 1"));
assert!(out.contains("pxeforge_dhcp_replies_total{arch=\"bios\"} 1"));
assert!(out.contains("pxeforge_tftp_transfers_total{status=\"ok\"} 1"));
assert!(out.contains("pxeforge_tftp_bytes_total 1024"));
assert!(out.contains("pxeforge_http_requests_total{route=\"api\"} 1"));
assert!(out.contains("pxeforge_iso_count 3"));
assert!(out.contains("pxeforge_uptime_seconds 42"));
assert!(out.contains("pxeforge_build_info{version=\"0.2.0\"} 1"));
}
#[test]
@@ -329,6 +278,6 @@ mod tests {
a.record_dhcp_reply("bios");
b.record_dhcp_reply("bios");
let out = a.render("test", 0);
assert!(out.contains("openpxe_dhcp_replies_total{arch=\"bios\"} 2"));
assert!(out.contains("pxeforge_dhcp_replies_total{arch=\"bios\"} 2"));
}
}
+12 -22
View File
@@ -47,13 +47,13 @@ pub struct Settings {
/// `timeout_action = LocalHdd`).
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.
/// When a client hits the Gated Deployment item, how long (seconds) to
/// 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
/// informational today — PXEForge does not run a DNS server, but
/// operators expect to be able to record what the upstream DNS is.
/// Empty string = unset (UI shows placeholder).
pub dns_server: String,
@@ -66,22 +66,21 @@ pub enum TimeoutAction {
Stay,
/// Chain the "Boot from Local HDD" entry.
LocalHdd,
/// Put the client into the deployment queue, waiting for operator
/// assignment.
/// Put the client into the gate queue, waiting for operator assignment.
#[default]
QueuedDeployment,
GatedDeployment,
}
impl Default for Settings {
fn default() -> Self {
Self {
boot_menu_timeout_secs: 600,
timeout_action: TimeoutAction::QueuedDeployment,
timeout_action: TimeoutAction::GatedDeployment,
windows_enabled: false,
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(),
}
}
@@ -104,7 +103,7 @@ impl SettingsStore {
Ok(s) => s,
Err(e) => {
tracing::warn!(
target: "openpxe::settings",
target: "pxeforge::settings",
"settings.json present but unreadable ({e}); falling back to defaults"
);
Settings::default()
@@ -112,10 +111,7 @@ impl SettingsStore {
},
Err(_) => Settings::default(),
};
Arc::new(Self {
path,
inner: RwLock::new(initial),
})
Arc::new(Self { path, inner: RwLock::new(initial) })
}
#[must_use]
@@ -134,7 +130,7 @@ impl SettingsStore {
}
let snap = self.snapshot();
if let Err(e) = self.persist(&snap) {
tracing::warn!(target: "openpxe::settings", "failed to persist settings: {e}");
tracing::warn!(target: "pxeforge::settings", "failed to persist settings: {e}");
}
}
@@ -161,7 +157,7 @@ mod tests {
let store = SettingsStore::load_or_default(dir.path());
let s = store.snapshot();
assert_eq!(s.boot_menu_timeout_secs, 600);
assert_eq!(s.timeout_action, TimeoutAction::QueuedDeployment);
assert_eq!(s.timeout_action, TimeoutAction::GatedDeployment);
assert!(!s.windows_enabled);
}
@@ -181,12 +177,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();
-318
View File
@@ -1,318 +0,0 @@
//! SAML SSO configuration — FleetDM-shaped, storage-only for v0.4.5.
//!
//! The operator pastes their IdP's metadata XML (or its URL) and a
//! human-readable label; v0.4.5 just persists it. The actual SAML
//! response-validation / JIT-provisioning flow lands in a later release
//! — for now we cover the "configurable" half so an operator can teach
//! OpenPXE about their IdP today and flip the switch on next upgrade.
//!
//! Shape borrowed from <https://github.com/fleetdm/fleet>'s app-config
//! SSO block, minus the user-RBAC fields (OpenPXE is single-tier: you
//! have access or you don't). Entity ID is omitted from the operator
//! UI per the v0.4.5 brief — it defaults to the advertised public base
//! URL when SAML wiring lands, which is what most IdPs expect anyway.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use crate::{Error, Result};
/// The configurable surface. `metadata` and `metadata_url` are mutually
/// exclusive at apply time (one or the other identifies the IdP); the
/// store keeps both fields so an operator can switch between them
/// without losing the inactive one.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SsoConfig {
/// Master switch — when false, all SSO machinery (planned for a
/// later release) is skipped regardless of the rest of the fields.
#[serde(default)]
pub enabled: bool,
/// Display name shown on the WebUI's login screen as the "Sign in
/// with X" button label. Empty/whitespace falls back to "SSO".
#[serde(default)]
pub idp_name: String,
/// Optional HTTPS URL pointing at the IdP's brand logo. Rendered
/// next to `idp_name` on the WebUI's login screen (FleetDM-style).
/// Length-capped at [`MAX_URL_LEN`]; empty is fine.
#[serde(default)]
pub idp_logo_url: String,
/// Raw SAML metadata XML pasted by the operator. Mutually exclusive
/// with `metadata_url`; if both are set, the URL wins at apply time
/// (operators typically forget about a stale XML paste).
#[serde(default)]
pub metadata: String,
/// HTTPS URL where the IdP serves its metadata. Loaded lazily by the
/// future SAML flow; not validated here beyond a basic length cap.
#[serde(default)]
pub metadata_url: String,
}
impl SsoConfig {
/// Returns `true` only when the config is *usable* — enabled, and
/// at least one of metadata/metadata_url is present. The future
/// login flow will key off this; for v0.4.5 the WebUI uses it to
/// surface a yellow "configured but not live yet" hint.
#[must_use]
pub fn is_usable(&self) -> bool {
self.enabled
&& (!self.metadata.trim().is_empty() || !self.metadata_url.trim().is_empty())
}
}
/// In-memory + on-disk SSO settings registry.
#[derive(Debug, Clone)]
pub struct SsoStore {
path: Arc<PathBuf>,
inner: Arc<RwLock<SsoConfig>>,
}
impl SsoStore {
/// Load from `<work_dir>/sso.json`, or start with the default empty
/// (`enabled = false`) config. A corrupt file falls back to default
/// rather than blocking startup.
#[must_use]
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("sso.json");
let cfg = match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str::<SsoConfig>(&text) {
Ok(parsed) => parsed,
Err(e) => {
tracing::warn!(
target: "openpxe::sso",
"sso.json present but unreadable ({e}); starting with default config"
);
SsoConfig::default()
}
},
Err(_) => SsoConfig::default(),
};
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(cfg)),
}
}
#[must_use]
pub fn snapshot(&self) -> SsoConfig {
self.inner.read().clone()
}
/// Replace the whole config in one shot. Light validation: metadata
/// XML and URL are length-capped so an operator can't OOM us by
/// pasting a 10 GiB blob; the IdP UI tab clamps the input visually,
/// but the server enforces a hard ceiling regardless.
pub fn replace(&self, mut cfg: SsoConfig) -> Result<SsoConfig> {
cfg.idp_name = cfg.idp_name.trim().to_string();
cfg.idp_logo_url = cfg.idp_logo_url.trim().to_string();
cfg.metadata = cfg.metadata.trim().to_string();
cfg.metadata_url = cfg.metadata_url.trim().to_string();
if cfg.metadata.len() > MAX_METADATA_BYTES {
return Err(Error::Invalid(format!(
"metadata XML exceeds {MAX_METADATA_BYTES}-byte cap"
)));
}
if cfg.metadata_url.len() > MAX_URL_LEN {
return Err(Error::Invalid(format!(
"metadata_url exceeds {MAX_URL_LEN}-char cap"
)));
}
if cfg.idp_logo_url.len() > MAX_URL_LEN {
return Err(Error::Invalid(format!(
"idp_logo_url exceeds {MAX_URL_LEN}-char cap"
)));
}
if !cfg.metadata_url.is_empty()
&& !cfg.metadata_url.starts_with("http://")
&& !cfg.metadata_url.starts_with("https://")
{
return Err(Error::Invalid(
"metadata_url must start with http:// or https://".into(),
));
}
if !cfg.idp_logo_url.is_empty()
&& !cfg.idp_logo_url.starts_with("http://")
&& !cfg.idp_logo_url.starts_with("https://")
{
return Err(Error::Invalid(
"idp_logo_url must start with http:// or https://".into(),
));
}
// If they're trying to *enable* the integration but haven't
// supplied either source, reject — saves a "configured but
// unusable" surprise later.
if cfg.enabled && cfg.metadata.is_empty() && cfg.metadata_url.is_empty() {
return Err(Error::Invalid(
"enable SSO requires either metadata XML or a metadata URL".into(),
));
}
{
let mut g = self.inner.write();
*g = cfg.clone();
}
self.persist();
tracing::info!(
target: "openpxe::sso",
enabled = cfg.enabled,
idp = %cfg.idp_name,
has_xml = !cfg.metadata.is_empty(),
has_url = !cfg.metadata_url.is_empty(),
"sso configuration updated"
);
Ok(cfg)
}
fn persist(&self) {
let snap = self.inner.read().clone();
let body = match serde_json::to_vec_pretty(&snap) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::sso", "serialize sso.json: {e}");
return;
}
};
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = self.path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::sso", "write sso.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::warn!(target: "openpxe::sso", "rename sso.json: {e}");
}
}
}
/// Saturation caps. The numbers are generous for any real IdP metadata
/// document — Okta's largest is ~50 KB, Azure AD's ~30 KB.
const MAX_METADATA_BYTES: usize = 1024 * 1024;
const MAX_URL_LEN: usize = 2048;
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn default_is_disabled_and_empty() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let cfg = s.snapshot();
assert!(!cfg.enabled);
assert!(cfg.metadata.is_empty());
assert!(cfg.metadata_url.is_empty());
assert!(!cfg.is_usable());
}
#[test]
fn replace_metadata_url_round_trip_via_disk() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
s.replace(SsoConfig {
enabled: true,
idp_name: "Okta".into(),
metadata: String::new(),
metadata_url: "https://idp.example.com/metadata".into(),
idp_logo_url: String::new(),
})
.unwrap();
drop(s);
let s2 = SsoStore::load_or_default(dir.path());
let cfg = s2.snapshot();
assert!(cfg.enabled);
assert!(cfg.is_usable());
assert_eq!(cfg.idp_name, "Okta");
assert_eq!(cfg.metadata_url, "https://idp.example.com/metadata");
}
#[test]
fn replace_xml_paste_is_accepted() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let xml = r#"<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata">test</EntityDescriptor>"#;
s.replace(SsoConfig {
enabled: true,
idp_name: "Test IdP".into(),
metadata: xml.into(),
metadata_url: String::new(),
idp_logo_url: String::new(),
})
.unwrap();
assert!(s.snapshot().is_usable());
}
#[test]
fn enable_without_source_is_rejected() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let r = s.replace(SsoConfig {
enabled: true,
idp_name: "Okta".into(),
metadata: String::new(),
metadata_url: String::new(),
idp_logo_url: String::new(),
});
assert!(matches!(r, Err(Error::Invalid(_))));
// …and a disabled blank config is fine.
s.replace(SsoConfig::default()).unwrap();
}
#[test]
fn metadata_url_must_be_http_scheme() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let r = s.replace(SsoConfig {
enabled: false,
idp_name: String::new(),
metadata: String::new(),
metadata_url: "ftp://idp.example.com/metadata".into(),
idp_logo_url: String::new(),
});
assert!(matches!(r, Err(Error::Invalid(_))));
}
#[test]
fn idp_logo_url_must_be_http_scheme() {
// v0.4.6: SSO settings learned an idp_logo_url so the login
// screen can render the FleetDM-style "Sign in with <IdP-logo>"
// affordance. Same scheme rule as metadata_url.
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let r = s.replace(SsoConfig {
enabled: false,
idp_name: "Okta".into(),
metadata: String::new(),
metadata_url: String::new(),
idp_logo_url: "data:image/png;base64,...".into(),
});
assert!(matches!(r, Err(Error::Invalid(_))));
// Real HTTPS URL is fine.
s.replace(SsoConfig {
enabled: false,
idp_name: "Okta".into(),
metadata: String::new(),
metadata_url: String::new(),
idp_logo_url: "https://idp.example.com/logo.png".into(),
})
.unwrap();
assert_eq!(s.snapshot().idp_logo_url, "https://idp.example.com/logo.png");
}
#[test]
fn metadata_size_cap_enforced() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let oversize = "a".repeat(MAX_METADATA_BYTES + 1);
let r = s.replace(SsoConfig {
enabled: false,
idp_name: String::new(),
metadata: oversize,
metadata_url: String::new(),
idp_logo_url: String::new(),
});
assert!(matches!(r, Err(Error::Invalid(_))));
}
}
+3 -3
View File
@@ -1,16 +1,16 @@
[package]
name = "openpxe-dhcp-proxy"
name = "pxeforge-dhcp-proxy"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "DHCP proxy (RFC 4578) for OpenPXE — serves boot info, does not lease IPs"
description = "DHCP proxy (RFC 4578) for PXEForge — serves boot info, does not lease IPs"
[lints]
workspace = true
[dependencies]
openpxe-core.workspace = true
pxeforge-core.workspace = true
tokio.workspace = true
socket2.workspace = true
dhcproto.workspace = true
+5 -15
View File
@@ -9,7 +9,7 @@
//! pass, or the HTTP URL of the boot script once iPXE has chained.
use dhcproto::v4::{DhcpOption, Message, MessageType, Opcode, OptionCode};
use openpxe_core::{ClientArch, FirmwareClass};
use pxeforge_core::{ClientArch, FirmwareClass};
use std::net::Ipv4Addr;
/// Where the reply directs the client next.
@@ -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,
}
+20 -41
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 pxeforge_core::{
ClientArch, ClientEvent, ClientRegistry, FirmwareClass,
};
use socket2::{Domain, Protocol, Socket, Type};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use std::sync::Arc;
@@ -17,7 +19,7 @@ pub struct DhcpProxyServer {
our_ip: Ipv4Addr,
public_base_url: String,
clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
metrics: pxeforge_core::Metrics,
}
impl DhcpProxyServer {
@@ -28,7 +30,7 @@ impl DhcpProxyServer {
our_ip: Ipv4Addr,
public_base_url: String,
clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
metrics: pxeforge_core::Metrics,
) -> Self {
Self {
bind,
@@ -45,7 +47,7 @@ impl DhcpProxyServer {
let dhcp_sock = bind_udp(self.bind, self.dhcp_port, true)?;
let pxe_sock = bind_udp(self.bind, self.pxe_port, false)?;
tracing::info!(
target: "openpxe::dhcp",
target: "pxeforge::dhcp",
"DHCP proxy listening on {}:{} and :{}",
self.bind, self.dhcp_port, self.pxe_port
);
@@ -65,12 +67,12 @@ impl DhcpProxyServer {
let (n, from) = match sock.recv_from(&mut buf).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(target: "openpxe::dhcp", port=label, "recv error: {e}");
tracing::warn!(target: "pxeforge::dhcp", port=label, "recv error: {e}");
continue;
}
};
if let Err(e) = self.handle_datagram(&sock, &buf[..n], from, label).await {
tracing::warn!(target: "openpxe::dhcp", port=label, "handle error: {e}");
tracing::warn!(target: "pxeforge::dhcp", port=label, "handle error: {e}");
}
}
}
@@ -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) {
@@ -137,23 +128,21 @@ impl DhcpProxyServer {
if matches!(directive, BootDirective::Ignore) {
self.metrics.record_dhcp_decline();
tracing::debug!(
target: "openpxe::dhcp",
target: "pxeforge::dhcp",
mac=%mac, arch=?arch, "ignoring — no bootfile for arch"
);
return Ok(());
}
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))?;
let dest = reply_destination(&request, from);
sock.send_to(&out, dest).await?;
tracing::info!(
target: "openpxe::dhcp",
target: "pxeforge::dhcp",
mac=%mac, arch=arch.as_str(), class=?class, dest=%dest, directive=?directive,
"PXE reply sent"
);
@@ -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 {
i += 1;
continue;
} // PAD
if code == 0xff { return None; } // END
if code == 0x00 { 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() {
@@ -261,7 +240,7 @@ mod tests {
// Minimal BOOTP header + magic cookie + option 93 (arch)=0x0007 + END.
let mut pkt = vec![0u8; 240];
pkt[236..240].copy_from_slice(&[99, 130, 83, 99]); // magic cookie
pkt.extend_from_slice(&[53, 1, 1]); // option 53 DHCPDISCOVER
pkt.extend_from_slice(&[53, 1, 1]); // option 53 DHCPDISCOVER
pkt.extend_from_slice(&[93, 2, 0x00, 0x07]);
pkt.push(0xff);
assert_eq!(extract_raw_arch(&pkt), Some(0x0007));
+5 -11
View File
@@ -1,5 +1,5 @@
[package]
name = "openpxe-http-api"
name = "pxeforge-http-api"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -10,10 +10,10 @@ description = "HTTP server: ISO uploads, iPXE script generation, ISO streaming"
workspace = true
[dependencies]
openpxe-core.workspace = true
openpxe-iso-store.workspace = true
openpxe-ipxe-assets.workspace = true
openpxe-webui.workspace = true
pxeforge-core.workspace = true
pxeforge-iso-store.workspace = true
pxeforge-ipxe-assets.workspace = true
pxeforge-webui.workspace = true
tokio.workspace = true
tokio-util.workspace = true
tokio-stream.workspace = true
@@ -31,9 +31,6 @@ bytes.workspace = true
futures.workspace = true
mime.workspace = true
mime_guess.workspace = true
uuid.workspace = true
# v0.4.5 Forms auth: lock-free session store and cookie helpers.
parking_lot.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] }
@@ -41,6 +38,3 @@ tower = { workspace = true }
tempfile = "3.12"
serde_json = { workspace = true }
time = { workspace = true }
# v0.4.61: integration tests need to generate real PNG bytes for the
# `/branding/pxe-logo` compositor; hand-rolled CRCs are too fragile.
image = { version = "0.25", default-features = false, features = ["png"] }
+156 -1231
View File
File diff suppressed because it is too large Load Diff
-485
View File
@@ -1,485 +0,0 @@
//! Forms auth layer — sessions, login, setup, middleware.
//!
//! Three states:
//!
//! * **Unconfigured** (`AdminStore::is_configured() == false`). The
//! middleware passes every request through — there's no one to gate
//! against. The UI's `/api/me` returns `setup_required: true` and the
//! front-end pushes the operator into the first-run flow.
//! * **Logged in**. The session cookie maps to an in-memory session
//! record with an idle expiry; `/api/me` returns the username.
//! * **Logged out**. The middleware bounces `/api/*` (with the PXE
//! allowlist below) to `401 Unauthorized`; the front-end intercepts
//! that and shows `/login`.
//!
//! Allowlist for unauthenticated access *after* the admin is set up:
//!
//! * everything outside `/api/*` (the WebUI bundle, asset chrome, PXE
//! script endpoints, the bundled iPXE/wimboot binaries, ISO bytes,
//! liveness/readiness probes, the Prometheus scrape) — these are
//! read-only or PXE-essential and breaking them locks out booting
//! machines that have no way to authenticate;
//! * `/api/setup`, `/api/login`, `/api/me` (the auth surface itself);
//! * `/api/queue/join`, `/api/queue/poll/:entry_id` (iPXE long-poll for
//! Queued Deployment — the iPXE client can't send a session cookie).
//!
//! Everything else inside `/api/*` requires a valid session.
use crate::state::AppState;
use axum::{
body::Body,
extract::{Request, State},
http::{header, HeaderValue, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Json,
};
use openpxe_core::AdminPublic;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use uuid::Uuid;
/// Idle session lifetime. Sliding — every authenticated request resets
/// the expiry. 24h is the Sonarr default and matches what most operators
/// expect for an on-prem admin console.
const SESSION_TTL: Duration = Duration::from_hours(24);
/// Name of the cookie we set/read. Distinct from a generic `session=`
/// to avoid collisions with anything else sharing the host.
pub const SESSION_COOKIE: &str = "openpxe_session";
#[derive(Debug, Clone)]
struct Session {
username: String,
expires_at: Instant,
}
/// In-memory session table. Cheap to clone (Arc-shared) and contention
/// is rare — operators sign in once per browser session.
#[derive(Debug, Clone, Default)]
pub struct SessionStore {
inner: Arc<RwLock<HashMap<String, Session>>>,
}
impl SessionStore {
/// Mint a fresh session for `username` and return the opaque cookie
/// value. UUID v4 gives us 122 random bits — comfortably more than
/// the 64-128 bits typical for session IDs.
#[must_use]
pub fn create(&self, username: &str) -> String {
let id = Uuid::new_v4().simple().to_string();
let session = Session {
username: username.to_string(),
expires_at: Instant::now() + SESSION_TTL,
};
self.inner.write().insert(id.clone(), session);
id
}
/// Resolve a cookie value to the owning username, refreshing the
/// idle timer. Returns `None` for missing / expired sessions and
/// proactively evicts the expired entry so the map doesn't grow
/// unbounded across long-lived deployments.
pub fn touch(&self, id: &str) -> Option<String> {
let mut g = self.inner.write();
let s = g.get_mut(id)?;
if s.expires_at <= Instant::now() {
g.remove(id);
return None;
}
s.expires_at = Instant::now() + SESSION_TTL;
Some(s.username.clone())
}
/// Invalidate one session (the user's `/api/logout`).
pub fn revoke(&self, id: &str) {
self.inner.write().remove(id);
}
/// Invalidate every session — used after a credentials rotation so
/// stale cookies for the old password can't keep operating.
pub fn revoke_all(&self) {
self.inner.write().clear();
}
/// Periodic / opportunistic GC. Not currently scheduled (we evict
/// on touch), but exposed for a future janitor task.
pub fn gc(&self) {
let now = Instant::now();
self.inner.write().retain(|_, s| s.expires_at > now);
}
#[must_use]
pub fn len(&self) -> usize {
self.inner.read().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
// ── Cookie helpers ────────────────────────────────────────────────────────
fn cookie_attrs(value: &str, max_age: Option<i64>) -> String {
// Same flags FleetDM and Sonarr ship by default:
// - HttpOnly: blocks JS access (XSS containment)
// - SameSite=Lax: allows top-level GET navigations from the IdP
// to land authenticated when SSO arrives, but blocks
// cross-site POST CSRF;
// - Path=/: the cookie applies to the whole app;
// - no Secure flag yet — many operators host on plain http://
// LAN IPs (Unraid templates default to that); we'll add Secure
// opportunistically when we add a TLS terminator option.
// SESSION_TTL fits in 32 bits comfortably (24h ≈ 86400 seconds); we
// never overflow i64, but clippy's `cast_possible_wrap` lint wants
// us to be explicit. `cast_signed` is the documented form.
let lifetime = max_age.unwrap_or_else(|| SESSION_TTL.as_secs().cast_signed());
format!(
"{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={lifetime}"
)
}
fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
// `Cookie: a=b; c=d` parsing — small enough not to drag in a crate.
let raw = headers.get(header::COOKIE)?.to_str().ok()?;
for part in raw.split(';') {
let part = part.trim();
if let Some(v) = part.strip_prefix(&format!("{SESSION_COOKIE}=")) {
return Some(v.to_string());
}
}
None
}
// ── Middleware ────────────────────────────────────────────────────────────
/// Return `true` if `path` is on the allowlist and should bypass the
/// session check. The middleware applies this rule only when the admin
/// account is configured; before then everything is open.
fn is_public_path(path: &str) -> bool {
// Non-API paths: WebUI bundle, PXE chain, ISO bytes, health probes,
// metrics. All read-only / PXE-essential.
if !path.starts_with("/api/") {
return true;
}
// Auth surface and iPXE long-poll endpoints (no cookie available).
matches!(
path,
"/api/setup" | "/api/login" | "/api/logout" | "/api/me"
) || path.starts_with("/api/queue/join")
|| path.starts_with("/api/queue/poll/")
}
/// Axum middleware: gate `/api/*` behind a valid session, with the
/// allowlist above. `State<AppState>` reaches in for the admin store +
/// session store.
pub async fn require_auth(
State(state): State<AppState>,
req: Request<Body>,
next: Next,
) -> Response {
// Bypass entirely while unconfigured. The /api/setup endpoint is
// the only one that can flip this back to "configured", and it
// refuses to run a second time. Tests + fresh installs ride this
// path.
if !state.admin.is_configured() {
return next.run(req).await;
}
let path = req.uri().path();
if is_public_path(path) {
return next.run(req).await;
}
// Authenticated path. The cookie must be present, map to a live
// session, and the TTL refresh happens as a side-effect.
let token = parse_cookie(req.headers());
if let Some(t) = token {
if state.sessions.touch(&t).is_some() {
return next.run(req).await;
}
}
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "authentication required" })),
)
.into_response()
}
// ── Handlers ──────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct SetupBody {
pub username: String,
pub password: String,
}
/// First-run setup. Refuses to run once an admin already exists — that
/// guards against a leaked WebUI being re-bootstrapped by an attacker
/// who's seen the deployment URL. After bootstrap, the new session
/// cookie is set so the operator goes straight to the dashboard.
pub async fn api_setup(
State(state): State<AppState>,
Json(body): Json<SetupBody>,
) -> Response {
if state.admin.is_configured() {
return (
StatusCode::CONFLICT,
Json(json!({ "error": "admin account already configured" })),
)
.into_response();
}
match state.admin.bootstrap(&body.username, &body.password) {
Ok(pub_) => {
let session = state.sessions.create(&pub_.username);
login_response(StatusCode::CREATED, &pub_, &session)
}
Err(openpxe_core::Error::Invalid(msg)) => {
(StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("{e}") })),
)
.into_response(),
}
}
#[derive(Debug, Deserialize)]
pub struct LoginBody {
pub username: String,
pub password: String,
}
pub async fn api_login(State(state): State<AppState>, Json(body): Json<LoginBody>) -> Response {
// Brief, deliberately vague — "invalid credentials" rather than
// "no such user" / "wrong password". Same anti-enumeration posture
// as Sonarr/Radarr.
let pub_ = match state.admin.verify(&body.username, &body.password) {
Ok(Some(u)) => u,
Ok(None) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "invalid username or password" })),
)
.into_response();
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("{e}") })),
)
.into_response();
}
};
let session = state.sessions.create(&pub_.username);
login_response(StatusCode::OK, &pub_, &session)
}
pub async fn api_logout(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
) -> Response {
if let Some(t) = parse_cookie(&headers) {
state.sessions.revoke(&t);
}
// Stomp the cookie unconditionally — even if the request didn't
// carry one, the browser shouldn't keep a stale value.
let mut resp = StatusCode::NO_CONTENT.into_response();
resp.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_str(&cookie_attrs("", Some(0))).unwrap(),
);
resp
}
/// Status surface for the front-end shell. Returns four cases:
///
/// * `setup_required: true` — no admin yet; show first-run page.
/// * `authenticated: false` — admin exists, no session; show login.
/// * `authenticated: true` + `user` — let the dashboard load.
pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
if !state.admin.is_configured() {
return (
StatusCode::OK,
Json(json!({
"setup_required": true,
"authenticated": false,
})),
)
.into_response();
}
let token = parse_cookie(&headers);
let username = token.as_deref().and_then(|t| state.sessions.touch(t));
match username {
Some(u) => (
StatusCode::OK,
Json(json!({
"setup_required": false,
"authenticated": true,
"user": state.admin.snapshot(),
"session_user": u,
})),
)
.into_response(),
None => (
StatusCode::OK,
Json(json!({
"setup_required": false,
"authenticated": false,
})),
)
.into_response(),
}
}
#[derive(Debug, Deserialize)]
pub struct UpdateCredentialsBody {
pub current_password: String,
#[serde(default)]
pub new_username: Option<String>,
#[serde(default)]
pub new_password: Option<String>,
}
/// Rotate the admin's username and/or password. Auth middleware has
/// already proved the caller owns a session; we additionally require
/// the *current* password to prove "person at the keyboard right now".
/// On success we issue a fresh session cookie keyed to the (possibly
/// new) username and revoke every prior session so a stolen cookie
/// from before the rotation stops working.
pub async fn api_update_credentials(
State(state): State<AppState>,
Json(body): Json<UpdateCredentialsBody>,
) -> Response {
if !state.admin.is_configured() {
return (
StatusCode::CONFLICT,
Json(json!({ "error": "no admin configured" })),
)
.into_response();
}
let result = state.admin.update_credentials(
&body.current_password,
body.new_username.as_deref(),
body.new_password.as_deref(),
);
match result {
Ok(pub_) => {
state.sessions.revoke_all();
let session = state.sessions.create(&pub_.username);
login_response(StatusCode::OK, &pub_, &session)
}
Err(openpxe_core::Error::Invalid(msg)) => {
(StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("{e}") })),
)
.into_response(),
}
}
#[derive(Debug, Serialize)]
struct LoginPayload<'a> {
user: &'a AdminPublic,
authenticated: bool,
}
fn login_response(status: StatusCode, user: &AdminPublic, session: &str) -> Response {
let body = Json(LoginPayload {
user,
authenticated: true,
});
let mut resp = (status, body).into_response();
resp.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_str(&cookie_attrs(session, None)).unwrap(),
);
resp
}
// ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_create_touch_revoke() {
let s = SessionStore::default();
assert!(s.is_empty());
let t = s.create("admin");
assert_eq!(s.len(), 1);
assert_eq!(s.touch(&t).as_deref(), Some("admin"));
s.revoke(&t);
assert!(s.is_empty());
// Stale token doesn't error, just returns None.
assert!(s.touch(&t).is_none());
}
#[test]
fn session_revoke_all_clears() {
let s = SessionStore::default();
let _ = s.create("a");
let _ = s.create("b");
assert_eq!(s.len(), 2);
s.revoke_all();
assert!(s.is_empty());
}
#[test]
fn public_path_allowlist() {
// PXE + chrome paths bypass auth.
for p in [
"/", "/assets/app.js", "/boot.ipxe", "/boot/fake.ipxe",
"/iso/fake.iso", "/ipxe/snponly.efi", "/healthz", "/readyz",
"/metrics",
// v0.4.6: iPXE fetches this for `console --picture` before
// it can possibly have a session cookie.
"/branding/pxe-logo",
] {
assert!(is_public_path(p), "expected {p} to be public");
}
// Auth surface itself is public.
for p in ["/api/setup", "/api/login", "/api/logout", "/api/me"] {
assert!(is_public_path(p), "expected {p} to be public");
}
// iPXE long-poll endpoints are public (no cookie available).
assert!(is_public_path("/api/queue/join"));
assert!(is_public_path("/api/queue/poll/abc"));
// Everything else under /api/* must auth.
for p in [
"/api/isos",
"/api/isos/x/category",
"/api/storage/disk",
"/api/branding/logo",
"/api/sso",
"/api/hosts",
] {
assert!(!is_public_path(p), "expected {p} to require auth");
}
}
#[test]
fn cookie_parse_picks_session_value() {
let mut h = axum::http::HeaderMap::new();
h.insert(
header::COOKIE,
HeaderValue::from_str(&format!("foo=bar; {SESSION_COOKIE}=abc123; baz=qux"))
.unwrap(),
);
assert_eq!(parse_cookie(&h).as_deref(), Some("abc123"));
// Different name → None.
let mut h2 = axum::http::HeaderMap::new();
h2.insert(header::COOKIE, HeaderValue::from_str("foo=bar").unwrap());
assert!(parse_cookie(&h2).is_none());
// No cookie header at all → None.
assert!(parse_cookie(&axum::http::HeaderMap::new()).is_none());
}
}
+56 -430
View File
@@ -8,12 +8,12 @@
//! > Boot from Local HDD
//! Installers
//! > Linux Installers -> submenu of Linux ISOs
//! > Windows Installers -> submenu of Windows ISOs (controlled by Settings::windows_enabled)
//! > Windows Installers -> submenu of Windows ISOs (gated by Settings::windows_enabled)
//! Tools
//! > Utilities -> memtest, etc. (embedded assets only)
//! > OpenPXE Shell -> drop to iPXE shell with branded prompt
//! > PXEForge Shell -> drop to iPXE shell with branded prompt
//! > Network Card Info -> ifstat / config / route dump
//! Queued Deployment -> join the deployment queue
//! Gated Deployment -> join the gate queue
//! ```
//!
//! ## iPXE is entirely backend — users do not see or write iPXE
@@ -22,29 +22,20 @@
//! those knobs into iPXE primitives (chain, menu, item, choose, etc.).
//! 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 pxeforge_core::{Settings, TimeoutAction};
use pxeforge_iso_store::{BootEntry, BootKind, IsoMeta};
use pxeforge_iso_store::introspect::DistroFamily;
use std::fmt::Write as _;
/// Top-level OpenPXE boot menu. Serialized identically for BIOS and UEFI
/// Top-level PXEForge boot menu. Serialized identically for BIOS and UEFI
/// clients because iPXE normalises the menu primitives across firmwares.
///
/// v0.4.6: rendered with an iVentoy-style polished frame — centered
/// OpenPXE wordmark banner at the top (ASCII so every iPXE build can
/// paint it), a footer carrying version + arch + firmware kind, and an
/// optional `console --picture` directive that paints the operator's
/// uploaded raster logo on top when the iPXE binary on the wire was
/// built with PNG support. The ASCII banner is always rendered so
/// even when the picture call no-ops the screen still reads as
/// "OpenPXE — here is the menu" rather than a featureless box.
#[must_use]
pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String {
let mut s = String::new();
let base = base_url.trim_end_matches('/');
let timeout_ms = settings.boot_menu_timeout_secs.saturating_mul(1000);
let default_item = match settings.timeout_action {
TimeoutAction::QueuedDeployment => "queue",
TimeoutAction::GatedDeployment => "gate",
// Stay -> iPXE's `--timeout 0` is "no timeout". Pick any default
// label; the client waits for keypress. We use the same label as
// LocalHdd to keep the menu's pre-highlight stable.
@@ -52,54 +43,15 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
};
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "# OpenPXE top-level menu - auto-generated, do not edit");
let _ = writeln!(s, "# PXEForge top-level menu - auto-generated, do not edit");
let _ = writeln!(s, "set base-url {base}");
let _ = writeln!(s, "set esc:hex 1b");
let _ = writeln!(s, "set cls ${{esc:string}}[2J");
// v0.4.6: best-effort graphics console with the operator-uploaded
// raster logo. Falls back to plain text console on iPXE builds
// without PNG support — the `||` chain keeps a parse-clean
// single-statement form so even the strictest iPXE parsers accept
// it. The `console` reset at the end re-syncs the menu output.
let _ = writeln!(
s,
"console --picture {base}/branding/pxe-logo || console"
);
// Map iPXE's ${{buildarch}} + ${{platform}} into the human form the
// user asked for (e.g. "x86 BIOS", "x86_64 UEFI", "arm64 UEFI").
// iPXE evaluates `iseq` lazily, so we only set whichever line
// matches. Anything not on the allowlist falls through to a generic
// `<buildarch> <platform>` display.
let _ = writeln!(s, "set arch-label ${{buildarch}} ${{platform}}");
let _ = writeln!(
s,
"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"
);
let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu OpenPXE - network boot menu");
// ASCII OpenPXE wordmark. Works on every iPXE build, including
// the boot.ipxe.org pre-builds we ship (which omit `IMAGE_PNG`,
// so `console --picture` paints nothing). When the queued iPXE
// source-build lands and the operator's uploaded raster actually
// paints via `console --picture`, this banner can be retired in
// favour of the real image. The compositor at
// /branding/pxe-logo is already wired and waiting.
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --gap -- ___ ___ __ __ ___");
let _ = writeln!(s, "item --gap -- / _ \\ _ __ ___ _ _ | _ \\ \\/ / | __|");
let _ = writeln!(s, "item --gap -- | (_) | '_ \\/ -_) ' \\ | _/ \\ / | _|");
let _ = writeln!(s, "item --gap -- \\___/| .__/\\___|_||_| |_| /_/\\_\\ |___|");
let _ = writeln!(s, "item --gap -- |_|");
let _ = writeln!(s, "item --gap");
let _ = writeln!(
s,
"item --gap -- ------------------------- Default -------------------------"
);
let _ = writeln!(s, "menu PXEForge - network boot menu");
let _ = writeln!(s, "item --gap -- ------------------------- Default -------------------------");
let _ = writeln!(s, "item local Boot from Local HDD");
let _ = writeln!(
s,
"item --gap -- ----------------------- Installers -----------------------"
);
let _ = writeln!(s, "item --gap -- ----------------------- Installers -----------------------");
if has_family(isos, is_linux_family) {
let _ = writeln!(s, "item linux Linux Installers >");
} else {
@@ -112,67 +64,28 @@ 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 queue Queued Deployment (join queue)");
let _ = writeln!(s, "item --gap -- ---------------------- Gated Deployment ---------------------");
let _ = writeln!(s, "item gate Gated Deployment (join queue)");
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key x exit Exit iPXE");
// v0.4.6 footer line. Sits just above the `choose` line so it's
// always visible regardless of how the menu paginates. iPXE
// interpolates `${arch-label}` (set near the top of this script)
// and `${version}` is the binary-baked iPXE version — *not* the
// OpenPXE version — so we hard-code the OpenPXE version string
// here.
let openpxe_version = env!("CARGO_PKG_VERSION");
let _ = writeln!(s, "item --gap");
let _ = writeln!(
s,
"item --gap -- OpenPXE v{openpxe_version} - ${{arch-label}}"
);
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}} gate && chain {base}/boot/_gate.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} exit && exit || goto menu");
let _ = writeln!(s, "goto menu");
s
}
@@ -182,51 +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 _ = writeln!(s, "menu PXEForge - {title}");
let filter: fn(DistroFamily) -> bool =
if is_windows { is_windows_family } else { is_linux_family };
let mut count = 0;
for iso in isos {
if !filter(iso.introspection.family) {
continue;
}
// v0.4.4: ISOs the operator flipped to the Tools category move
// out of the OS installer submenus entirely — they only appear
// under Tools. Without this filter the operator would see the
// same ISO in both menus.
if matches!(iso.category, openpxe_iso_store::IsoCategory::Tools) {
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),
);
@@ -239,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. 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
}
@@ -271,54 +148,17 @@ fn hotkey_for_index(i: usize) -> String {
}
}
/// Tools submenu — Utilities, Shell, NIC Info, Reboot, Exit to firmware,
/// plus any ISOs the operator flipped to [`IsoCategory::Tools`] in the
/// Storage tab. The category-Tools ISOs render first so frequently used
/// recovery / hardware tools are reachable with a single number key
/// before the built-in shortcuts.
/// Tools submenu — Utilities, Shell, NIC Info, Reboot, Exit to firmware.
#[must_use]
pub fn render_tools_menu(isos: &[IsoMeta], base_url: &str) -> String {
pub fn render_tools_menu(base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "set base-url {base}");
let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu OpenPXE - Tools");
// Operator-categorized tool ISOs (hotkeys 1..9), each chained the
// same way as a per-family menu pick — through the boot-entry id
// route, carrying `?mac=${mac}` for Host log attribution.
let mut count = 0;
for iso in isos {
if !matches!(iso.category, openpxe_iso_store::IsoCategory::Tools) {
continue;
}
for entry in &iso.boot_entries {
let size_label = fmt_size_mib(iso.size_bytes);
let key = hotkey_for_index(count);
let lock = if iso.is_password_protected() {
"*"
} else {
" "
};
let _ = writeln!(
s,
"item {}{} {}[{:>6}] {}",
key,
entry.id,
lock,
size_label,
escape_label(&entry.title),
);
count += 1;
}
}
if count > 0 {
let _ = writeln!(s, "item --gap");
}
let _ = writeln!(s, "menu PXEForge - Tools");
let _ = writeln!(s, "item --key u util Utilities (memtest, ...)");
let _ = writeln!(s, "item --key s shell OpenPXE Shell");
let _ = writeln!(s, "item --key s shell PXEForge Shell");
let _ = writeln!(s, "item --key n nic Network Card Info");
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key r reboot Reboot Computer");
@@ -326,36 +166,13 @@ pub fn render_tools_menu(isos: &[IsoMeta], 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"
);
// Fall-through for category-Tools ISO ids — same as the family
// submenu, carrying `?mac=${mac}` for the boot log.
let _ = writeln!(
s,
"chain {base}/boot/${{target}}.ipxe?mac=${{mac}} || 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
}
@@ -368,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");
@@ -392,31 +202,25 @@ pub fn render_util(base_url: &str) -> String {
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu OpenPXE - Utilities");
let _ = writeln!(s, "menu PXEForge - Utilities");
let _ = writeln!(s, "item memtest MemTest86+ (RAM diagnostic)");
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item back < Back");
let _ = writeln!(s, "choose target || goto menu");
let _ = writeln!(
s,
"iseq ${{target}} memtest && chain {base}/ipxe/memtest.bin || 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
}
/// "OpenPXE Shell" — iPXE shell, branded.
/// "PXEForge Shell" — iPXE shell, branded.
#[must_use]
pub fn render_shell(base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "echo ==========================================");
let _ = writeln!(s, "echo OpenPXE Shell");
let _ = writeln!(s, "echo PXEForge Shell");
let _ = writeln!(s, "echo 'exit' returns to the main menu");
let _ = writeln!(s, "echo ==========================================");
let _ = writeln!(s, "shell");
@@ -443,23 +247,20 @@ pub fn render_nic_info(base_url: &str) -> String {
s
}
/// Queued Deployment entry point. Joins the queue, then enters a long-poll
/// Gated Deployment entry point. Joins the queue, then enters a long-poll
/// loop (iPXE repeats the chain on 3xx redirects / HTTP errors until a
/// real script comes back).
#[must_use]
pub fn render_queue_entry(base_url: &str) -> String {
pub fn render_gate_entry(base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(
s,
"# Queued Deployment - join the queue and wait for operator"
);
let _ = writeln!(s, "echo Joining deployment queue...");
let _ = writeln!(s, "# Gated Deployment - join the queue and wait for operator");
let _ = writeln!(s, "echo Joining gate queue...");
// imgfetch writes the body to a file in iPXE's transient FS; we read
// the queue entry id out of the Location-style header by asking the server
// the gate id out of the Location-style header by asking the server
// to put it in the response body as a single token.
let _ = writeln!(s, "chain --replace {base}/api/queue/join?mac=${{mac}}");
let _ = writeln!(s, "chain --replace {base}/api/gate/join?mac=${{mac}}");
s
}
@@ -471,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(' ');
@@ -526,176 +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. 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 top_menu_has_polished_branding_and_arch_footer() {
// v0.4.6 polish + v0.4.62 stability fixes: the menu emits a
// `console --picture` line that PNG-capable iPXE builds will
// honour (queued for a follow-up release once we can rebuild
// iPXE from source on native x86_64 hardware), an ASCII
// OpenPXE wordmark that works on every iPXE build (including
// the boot.ipxe.org pre-builds we currently ship), and a
// single-line footer carrying the OpenPXE version + arch.
let settings = Settings::default();
let s = render_menu(&[], &settings, "http://10.0.0.5");
assert!(
s.contains("console --picture http://10.0.0.5/branding/pxe-logo"),
"missing console --picture line:\n{s}"
);
// Picture-or-text-console must be a single statement so older
// iPXE parsers don't choke on the chain.
assert!(s.contains("|| console"), "missing graceful fallback:\n{s}");
// ASCII wordmark — paints on every iPXE build regardless of
// PNG support.
assert!(
s.contains("___ ___ __ __ ___"),
"ASCII banner missing first row:\n{s}"
);
// Footer with version + arch interpolation. The version comes
// from CARGO_PKG_VERSION at compile time.
let version = env!("CARGO_PKG_VERSION");
assert!(
s.contains(&format!("OpenPXE v{version}")),
"footer missing OpenPXE version:\n{s}"
);
assert!(
s.contains("${arch-label}"),
"footer missing arch-label interpolation:\n{s}"
);
// No website URL — the design brief calls that out as tacky.
assert!(
!s.to_ascii_lowercase().contains("openpxe.com"),
"footer should not advertise the website:\n{s}"
);
// Arch-label mapping covers the three labels from the brief:
// "x86 BIOS", "x86_64 UEFI", "arm64 UEFI".
assert!(s.contains("x86 BIOS"), "{s}");
assert!(s.contains("x86_64 UEFI"), "{s}");
assert!(s.contains("arm64 UEFI"), "{s}");
}
#[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 }
}
+1 -3
View File
@@ -1,5 +1,5 @@
//! HTTP server — single axum app that serves:
//! - `/` the web UI (static assets from `openpxe-webui`)
//! - `/` the web UI (static assets from `pxeforge-webui`)
//! - `/api/*` JSON API for the web UI
//! - `/boot.ipxe` the generated top-level iPXE boot menu
//! - `/boot/<entry>.ipxe` per-entry iPXE scripts (one per boot target)
@@ -14,13 +14,11 @@
#![forbid(unsafe_code)]
pub mod app;
pub mod auth;
pub mod ipxe_script;
pub mod iso_fs;
pub mod log_stream;
pub mod state;
pub mod terminal;
pub mod uploads;
pub use app::build_router;
pub use state::AppState;
+7 -11
View File
@@ -12,7 +12,7 @@ use axum::{
Json,
};
use futures::stream::{Stream, StreamExt};
use openpxe_core::LogLine;
use pxeforge_core::LogLine;
use serde_json::json;
use std::convert::Infallible;
use std::time::Duration;
@@ -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()
.event("lagged")
.data(json!({ "skipped": n }).to_string()))
}
Err(tokio_stream::wrappers::errors::BroadcastStreamRecvError::Lagged(n)) => Ok(Event::default()
.event("lagged")
.data(json!({ "skipped": n }).to_string())),
});
Sse::new(recent_stream.chain(live))
@@ -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", "pxeforge::terminal", "log buffer cleared by operator");
Json(json!({ "ok": true }))
}
+3 -31
View File
@@ -1,10 +1,5 @@
use crate::uploads::UploadSessions;
use crate::auth::SessionStore;
use openpxe_core::{
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
Metrics, SettingsStore, SsoStore,
};
use openpxe_iso_store::{IsoStore, NfsManager, SmbManager};
use pxeforge_core::{ClientRegistry, GateQueue, HostBindings, LogBus, Metrics, SettingsStore};
use pxeforge_iso_store::{IsoStore, NfsManager, SmbManager};
use std::sync::Arc;
use time::OffsetDateTime;
@@ -13,30 +8,11 @@ pub struct AppState {
pub iso_store: IsoStore,
pub clients: Arc<ClientRegistry>,
pub settings: Arc<SettingsStore>,
pub queue: Arc<DeploymentQueue>,
pub gates: Arc<GateQueue>,
/// Per-MAC iPXE script overrides. When a client matching one of
/// 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,
/// Operator-controlled UI overrides (custom logo). When the
/// operator hasn't uploaded anything, the WebUI serves the bundled
/// rainbow-horizon mark.
pub branding: BrandingStore,
/// Forms-auth admin record + first-run bootstrap state. When
/// `admin.is_configured() == false`, the auth middleware passes
/// every request through and `/api/me` reports `setup_required`.
pub admin: AdminStore,
/// In-memory session table for active operator logins. Cleared on
/// process restart (sessions are tied to UI state, not persisted —
/// matches Sonarr/Radarr behaviour).
pub sessions: SessionStore,
/// SAML SSO configuration. v0.4.5 stores it; the actual SSO login
/// flow ships in a later release.
pub sso: SsoStore,
/// Lock-free metrics counters surfaced at `/metrics` in Prometheus
/// text format. Cheap to clone (handles to atomics).
pub metrics: Metrics,
@@ -49,10 +25,6 @@ pub struct AppState {
/// available in the runtime image. Surfaces errors per-mount rather
/// than failing the global state.
pub nfs: NfsManager,
/// Browser chunked upload state. Multipart uploads still go straight
/// through `IsoStore`, but the UI uses sessions so large ISO transfers
/// can show deterministic progress and leave visible partial files.
pub uploads: UploadSessions,
/// Live log bus consumed by the Terminal tab via SSE. Operator-issued
/// terminal commands also push synthetic lines onto it so the tail
/// shows them inline.
+66 -92
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", "pxeforge::terminal", format!("> {line}"));
let argv = shell_split(line);
if argv.is_empty() {
@@ -60,18 +55,14 @@ 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()
};
if ok {
state.log_bus.push("info", "openpxe::terminal", mirror);
state.log_bus.push("info", "pxeforge::terminal", mirror);
} else {
state.log_bus.push("warn", "openpxe::terminal", mirror);
state.log_bus.push("warn", "pxeforge::terminal", mirror);
}
(StatusCode::OK, Json(json!({ "output": output, "ok": ok })))
@@ -82,12 +73,12 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
let tail = &argv[1..];
match head {
"help" | "?" => Ok(HELP_TEXT.to_string()),
"version" => Ok(format!("openpxe {}", env!("CARGO_PKG_VERSION"))),
"version" => Ok(format!("pxeforge {}", env!("CARGO_PKG_VERSION"))),
"uptime" => Ok(uptime_string(state)),
"status" => Ok(status_text(state)),
"isos" | "images" => Ok(isos_text(state)),
"clients" => Ok(clients_text(state)),
"queue" => queue_command(state, tail).await,
"gate" => gate_command(state, tail).await,
"nfs" => nfs_command(state, tail).await,
"smb" => smb_command(state, tail).await,
"log" => log_command(state, tail),
@@ -105,39 +96,29 @@ 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.gates.list();
let smb = s.smb.as_ref().map(|m| m.snapshot());
let nfs = s.nfs.list();
let nfs_active = nfs.iter().filter(|m| m.mounted).count();
format!(
"OpenPXE {ver}\n\
"PXEForge {ver}\n\
base url: {base}\n\
interface: {nic}\n\
uptime: {up}\n\
isos: {n_isos} (local: {n_local}, nfs: {n_nfs})\n\
clients: {n_clients}\n\
queue: {n_entries}\n\
gates: {n_gates}\n\
smb: {smb}\n\
nfs mounts: {n_total} configured ({n_active} active)\n",
ver = env!("CARGO_PKG_VERSION"),
base = s.public_base_url,
nic = if s.nic_name.is_empty() {
"?"
} else {
s.nic_name.as_str()
},
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, pxeforge_iso_store::IsoSource::Local)).count(),
n_nfs = isos.iter().filter(|i| !matches!(i.source, pxeforge_iso_store::IsoSource::Local)).count(),
n_clients = clients.len(),
n_entries = queue_entries.len(),
n_gates = gates.len(),
smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")),
n_total = nfs.len(),
n_active = nfs_active,
@@ -157,8 +138,8 @@ fn isos_text(s: &AppState) -> String {
);
for i in isos {
let src = match i.source {
openpxe_iso_store::IsoSource::Local => "local".to_string(),
openpxe_iso_store::IsoSource::Nfs { mount_id, .. } => format!("nfs:{mount_id}"),
pxeforge_iso_store::IsoSource::Local => "local".to_string(),
pxeforge_iso_store::IsoSource::Nfs { mount_id, .. } => format!("nfs:{mount_id}"),
};
let _ = writeln!(
out,
@@ -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.gates.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()
@@ -232,34 +217,32 @@ async fn queue_command(s: &AppState, args: &[String]) -> Result<String, String>
if !found {
return Err(format!("no such boot entry: {target}"));
}
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}"))
let ids: Vec<_> = s.gates.list().into_iter().map(|g| g.id).collect();
let n = s.gates.assign(&ids, target);
Ok(format!("assigned {n} gates -> {target}"))
}
Some("assign") => {
let entry_id = args
let gate_id = args
.get(1)
.ok_or_else(|| "usage: queue assign <entry_id> <iso_boot_entry_id>".to_string())?;
.ok_or_else(|| "usage: gate assign <gate_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())?;
let n = s.queue.assign(std::slice::from_ref(entry_id), target);
.ok_or_else(|| "usage: gate assign <gate_id> <iso_boot_entry_id>".to_string())?;
let n = s.gates.assign(std::slice::from_ref(gate_id), target);
if n == 0 {
return Err(format!("no such queue entry: {entry_id}"));
return Err(format!("no such gate: {gate_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())?;
match s.queue.release(entry_id) {
Some(_) => Ok(format!("released {entry_id}")),
None => Err(format!("no such queue entry: {entry_id}")),
let gate_id = args.get(1).ok_or_else(|| "usage: gate release <gate_id>".to_string())?;
match s.gates.release(gate_id) {
Some(_) => Ok(format!("released {gate_id}")),
None => Err(format!("no such gate: {gate_id}")),
}
}
Some(other) => Err(format!(
"unknown queue subcommand: {other}\ntry: queue [list|assign-all|assign|release]"
"unknown gate subcommand: {other}\ntry: gate [list|assign-all|assign|release]"
)),
}
}
@@ -286,8 +269,8 @@ async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
"{:<24} {:<6} {:<7} {:<6} {}:{}",
truncate(&m.id, 24),
match m.version {
openpxe_iso_store::NfsVersion::V3 => "v3",
openpxe_iso_store::NfsVersion::V41 => "v4.1",
pxeforge_iso_store::NfsVersion::V3 => "v3",
pxeforge_iso_store::NfsVersion::V41 => "v4.1",
},
status,
m.iso_count,
@@ -309,14 +292,12 @@ async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
.split_once(':')
.ok_or_else(|| "target must be 'server:/export'".to_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("v3") => pxeforge_iso_store::NfsVersion::V3,
Some("v41") | None => pxeforge_iso_store::NfsVersion::V41,
Some(other) => return Err(format!("unknown nfs version: {other} (expect v3 or v41)")),
};
let read_only = !matches!(args.get(3).map(String::as_str), Some("rw"));
let req = openpxe_iso_store::NfsAddRequest {
let req = pxeforge_iso_store::NfsAddRequest {
server: server.to_string(),
export: export.to_string(),
version,
@@ -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();
@@ -473,7 +453,7 @@ pub fn shell_split(input: &str) -> Vec<String> {
}
const HELP_TEXT: &str = "\
OpenPXE terminal — available commands:
PXEForge terminal — available commands:
help show this help
version print server version
@@ -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 <gate_id> <target> assign one gate to a boot entry
gate assign-all <target> assign every waiting gate
gate release <gate_id> release one gate
nfs list list NFS mounts
nfs mount <s>:<e> [v3|v41] [ro|rw] add and mount an NFS share
@@ -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"));
}
}
-180
View File
@@ -1,180 +0,0 @@
//! Chunked upload sessions for browser-driven ISO uploads.
//!
//! The legacy multipart endpoint still exists for simple API clients, but
//! browsers get a better failure mode with raw chunks: progress advances after
//! each acknowledged write, partial files appear in the ISO directory
//! immediately, and reverse proxies are less likely to buffer an entire DVD
//! image before OpenPXE sees byte one.
use bytes::Bytes;
use openpxe_core::{Error, Result};
use openpxe_iso_store::{IsoMeta, IsoStore, UploadHandle};
use serde::Serialize;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use uuid::Uuid;
const DEFAULT_CHUNK_SIZE: u64 = 8 * 1024 * 1024;
#[derive(Clone, Default)]
pub struct UploadSessions {
inner: Arc<Mutex<HashMap<String, Arc<Mutex<UploadSession>>>>>,
}
struct UploadSession {
filename: String,
expected_size: Option<u64>,
offset: u64,
handle: Option<UploadHandle>,
}
#[derive(Debug, Clone, Serialize)]
pub struct UploadStarted {
pub upload_id: String,
pub iso_id: String,
pub filename: String,
pub offset: u64,
pub chunk_size: u64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum UploadAppend {
Progress { offset: u64 },
Complete { offset: u64, iso: Box<IsoMeta> },
}
impl UploadSessions {
pub async fn begin(
&self,
store: &IsoStore,
filename: &str,
expected_size: Option<u64>,
) -> Result<UploadStarted> {
if !filename.to_ascii_lowercase().ends_with(".iso") {
return Err(Error::Invalid("only .iso uploads accepted".to_string()));
}
let handle = store.begin_upload(filename).await?;
let iso_id = handle.id.clone();
let upload_id = Uuid::new_v4().to_string();
let session = UploadSession {
filename: filename.to_string(),
expected_size,
offset: 0,
handle: Some(handle),
};
self.inner
.lock()
.await
.insert(upload_id.clone(), Arc::new(Mutex::new(session)));
Ok(UploadStarted {
upload_id,
iso_id,
filename: filename.to_string(),
offset: 0,
chunk_size: DEFAULT_CHUNK_SIZE,
})
}
pub async fn append(
&self,
store: &IsoStore,
upload_id: &str,
offset: u64,
chunk: Bytes,
complete: bool,
) -> Result<UploadAppend> {
let Some(session_lock) = self.inner.lock().await.get(upload_id).cloned() else {
return Err(Error::Invalid(format!("no such upload '{upload_id}'")));
};
let mut session = session_lock.lock().await;
if session.offset != offset {
return Err(Error::Invalid(format!(
"expected offset {}, got {offset}",
session.offset
)));
}
let new_offset = session
.offset
.checked_add(chunk.len() as u64)
.ok_or_else(|| Error::Invalid("upload offset overflow".to_string()))?;
if let Some(expected) = session.expected_size {
if new_offset > expected {
return Err(Error::Invalid(format!(
"chunk exceeds declared upload size {expected}"
)));
}
}
let Some(handle) = session.handle.as_mut() else {
return Err(Error::Invalid("upload already completed".to_string()));
};
if let Err(e) = handle.write_chunk(&chunk).await {
let handle = session.handle.take();
drop(session);
self.inner.lock().await.remove(upload_id);
if let Some(handle) = handle {
let _ = handle.abort().await;
}
return Err(e);
}
session.offset = new_offset;
if !complete {
return Ok(UploadAppend::Progress { offset: new_offset });
}
if let Some(expected) = session.expected_size {
if new_offset != expected {
return Err(Error::Invalid(format!(
"final chunk ended at {new_offset}, expected {expected}"
)));
}
}
let Some(handle) = session.handle.take() else {
return Err(Error::Invalid("upload already completed".to_string()));
};
let filename = session.filename.clone();
drop(session);
tracing::info!(
target: "openpxe::http::upload",
upload_id,
filename = %filename,
received_bytes = new_offset,
"chunked upload body complete; introspecting"
);
let meta = match handle.finish(store).await {
Ok(meta) => meta,
Err(e) => {
self.inner.lock().await.remove(upload_id);
return Err(e);
}
};
self.inner.lock().await.remove(upload_id);
Ok(UploadAppend::Complete {
offset: new_offset,
iso: Box::new(meta),
})
}
pub async fn abort(&self, upload_id: &str) -> Result<()> {
let Some(session_lock) = self.inner.lock().await.remove(upload_id) else {
return Err(Error::Invalid(format!("no such upload '{upload_id}'")));
};
let mut session = session_lock.lock().await;
if let Some(handle) = session.handle.take() {
handle.abort().await?;
}
Ok(())
}
}
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,16 +1,16 @@
[package]
name = "openpxe-ipxe-assets"
name = "pxeforge-ipxe-assets"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "Bundled iPXE binaries and default chain scripts for OpenPXE"
description = "Bundled iPXE binaries and default chain scripts for PXEForge"
[lints]
workspace = true
[dependencies]
openpxe-core.workspace = true
pxeforge-core.workspace = true
rust-embed.workspace = true
tracing.workspace = true
thiserror.workspace = true
+5 -7
View File
@@ -1,7 +1,7 @@
//! Bundled iPXE boot binaries and default chain script.
//!
//! At build time, we expect the iPXE binaries to live at `assets/ipxe/` at
//! the workspace root. They are embedded into the OpenPXE binary via
//! the workspace root. They are embedded into the PXEForge binary via
//! `rust-embed` so the container image is self-contained. If a binary is
//! missing, that architecture simply won't have PXE support — we log at
//! startup and serve what we have.
@@ -16,7 +16,7 @@
//! - `wimboot` — Windows boot shim (fetched separately for WIM chains)
#![forbid(unsafe_code)]
use openpxe_core::ClientArch;
use pxeforge_core::ClientArch;
use rust_embed::Embed;
#[derive(Embed)]
@@ -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.
@@ -68,10 +66,10 @@ pub fn log_availability() {
];
for (arch, name) in needed {
if have.contains(name) {
tracing::info!(target: "openpxe::ipxe", "bundled iPXE for {}: {}", arch.as_str(), name);
tracing::info!(target: "pxeforge::ipxe", "bundled iPXE for {}: {}", arch.as_str(), name);
} else {
tracing::warn!(
target: "openpxe::ipxe",
target: "pxeforge::ipxe",
"MISSING iPXE binary for {}: {} — clients of this arch will not PXE boot",
arch.as_str(), name
);
+3 -11
View File
@@ -1,16 +1,16 @@
[package]
name = "openpxe-iso-store"
name = "pxeforge-iso-store"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "ISO upload, storage, introspection, and boot-entry generation for OpenPXE"
description = "ISO upload, storage, introspection, and boot-entry generation for PXEForge"
[lints]
workspace = true
[dependencies]
openpxe-core.workspace = true
pxeforge-core.workspace = true
tokio = { workspace = true }
tokio-util = { workspace = true }
serde.workspace = true
@@ -20,20 +20,12 @@ 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
bytes.workspace = true
tempfile = "3.12"
libc = "0.2"
# v0.4.61: server-side compose of the operator's uploaded raster into a
# fixed 1024x768 canvas so the PXE menu always gets a consistently-sized
# PNG regardless of what the operator uploaded. We use the bare-bones
# `image` crate (no default features) and explicitly enable only the
# decoders we accept on upload (PNG/JPEG/WebP/GIF) plus the PNG
# encoder. Keeps the build slim — no JPEG2000, TIFF, BMP, etc.
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
[dev-dependencies]
tempfile = "3.12"
+12 -39
View File
@@ -49,7 +49,7 @@ pub fn introspect(path: &Path) -> IntrospectionReport {
};
let Ok(mut f) = std::fs::File::open(path) else {
tracing::warn!(target: "openpxe::iso", "cannot open ISO for introspection: {}", path.display());
tracing::warn!(target: "pxeforge::iso", "cannot open ISO for introspection: {}", path.display());
return report;
};
@@ -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 -5
View File
@@ -19,7 +19,6 @@
pub mod entry;
pub mod introspect;
pub mod nfs;
pub mod pxe_logo;
pub mod smb;
pub mod store;
pub mod windows;
@@ -28,8 +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, IsoCategory, IsoMeta, IsoSource, IsoStore,
UploadHandle,
};
pub use store::{generate_boot_entries_for, slugify_str, IsoMeta, IsoSource, IsoStore, UploadHandle};
pub use windows::{WimPatcher, WinPatchState};
+17 -11
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 pxeforge_core::{Error, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
@@ -76,7 +76,7 @@ pub struct NfsMount {
pub export: String,
pub version: NfsVersion,
/// Read-only by default — most ISO libraries are. Operators that need
/// write can flip this off but OpenPXE itself never writes.
/// write can flip this off but PXEForge itself never writes.
pub read_only: bool,
/// Local mount point under `<work_dir>/nfs/`.
pub local_path: PathBuf,
@@ -168,7 +168,7 @@ impl NfsManager {
self.inner.lock().mounts.insert(m.id.clone(), m.clone());
if let Err(e) = self.try_mount(&m.id).await {
tracing::warn!(
target: "openpxe::nfs",
target: "pxeforge::nfs",
id = %m.id, error = %e,
"could not remount NFS share on startup"
);
@@ -299,7 +299,7 @@ impl NfsManager {
match output {
Ok(out) if out.status.success() => {
tracing::info!(
target: "openpxe::nfs",
target: "pxeforge::nfs",
id = %id, server = %m.server, export = %m.export,
version = ?m.version,
"NFS mount succeeded"
@@ -315,13 +315,13 @@ impl NfsManager {
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stderr).trim()
);
tracing::warn!(target: "openpxe::nfs", id = %id, "{err}");
tracing::warn!(target: "pxeforge::nfs", id = %id, "{err}");
self.update_status(id, false, Some(err.clone()), now);
Err(Error::Invalid(err))
}
Err(e) => {
let err = format!("could not exec /bin/mount: {e}");
tracing::error!(target: "openpxe::nfs", id = %id, "{err}");
tracing::error!(target: "pxeforge::nfs", id = %id, "{err}");
self.update_status(id, false, Some(err.clone()), now);
Err(Error::Invalid(err))
}
@@ -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)
@@ -436,7 +442,7 @@ impl NfsManager {
let body = match serde_json::to_vec_pretty(&mounts) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::nfs", "serialize NFS state: {e}");
tracing::warn!(target: "pxeforge::nfs", "serialize NFS state: {e}");
return;
}
};
@@ -444,11 +450,11 @@ impl NfsManager {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::nfs", "write NFS state tmp: {e}");
tracing::warn!(target: "pxeforge::nfs", "write NFS state tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, path) {
tracing::warn!(target: "openpxe::nfs", "rename NFS state: {e}");
tracing::warn!(target: "pxeforge::nfs", "rename NFS state: {e}");
}
}
}
-152
View File
@@ -1,152 +0,0 @@
//! Operator-logo compositor for the iPXE menu.
//!
//! The brief: match iVentoy's polished centered-logo PXE chrome with
//! whatever raster the operator drops onto Settings → Branding. A wide
//! wordmark, a portrait stack, a square monogram — all three should
//! land in roughly the same place on the boot screen.
//!
//! Approach: decode the operator's upload, fit it into a fixed
//! 1024×768 canvas with the logo horizontally centered and pinned a
//! short margin from the top, re-encode as PNG, return the bytes. iPXE
//! built with `IMAGE_PNG` paints the result via `console --picture`.
//!
//! The 1024×768 size matches the default VESA framebuffer iPXE picks
//! on most BIOS/UEFI consoles. Operators uploading 4K logos get
//! correctly downscaled; tiny icons get drawn at their native size,
//! centered, with transparent margins.
//!
//! We deliberately don't ship `resvg` for SVG support — keeping the
//! dependency surface narrow matters more than supporting SVG-only
//! brand assets. The WebUI's logo stays SVG-native (the browser
//! rasterizes it); the PXE menu wants a raster regardless.
use image::imageops::FilterType;
use image::{DynamicImage, ImageError, ImageFormat, Rgba, RgbaImage};
use std::io::Cursor;
/// Canvas dimensions used for the composed PXE logo. Picked to match
/// the framebuffer dimensions iPXE picks on most BIOS/UEFI consoles —
/// gives a 1:1 paint with no scaling at the firmware layer.
pub const CANVAS_W: u32 = 1024;
pub const CANVAS_H: u32 = 768;
/// Maximum dimensions for the operator's logo inside the canvas. Any
/// upload larger than this in either axis is downscaled (preserving
/// aspect ratio) to fit. Smaller uploads paint at native size.
const LOGO_MAX_W: u32 = 600;
const LOGO_MAX_H: u32 = 200;
/// Top margin in pixels from the canvas's top edge to the logo's top
/// edge. Matches the visual rhythm of iVentoy's screen (logo at top,
/// menu below).
const LOGO_TOP_MARGIN: u32 = 64;
/// Compose `src_bytes` (any PNG/JPEG/WebP/GIF) into a centered-top
/// 1024×768 PNG and return the encoded bytes.
///
/// Errors when the source can't be decoded or the encoded buffer can't
/// be written (only really fires on out-of-memory; the encoder itself
/// is infallible for well-formed inputs).
pub fn compose_pxe_logo(src_bytes: &[u8]) -> Result<Vec<u8>, ImageError> {
let logo = image::load_from_memory(src_bytes)?;
// Resize-fit if the upload exceeds our bounding box. `Lanczos3`
// keeps the antialiasing crisp on the framebuffer console; it's a
// touch slower than `Triangle` but the operator hits this endpoint
// once per boot at most.
let logo = downscale_to_fit(logo, LOGO_MAX_W, LOGO_MAX_H);
let logo_rgba = logo.to_rgba8();
// Transparent canvas. iPXE 1.21+ honours alpha-channel transparency
// on framebuffer consoles; older builds simply draw the alpha as
// black, which still gives a sensible look.
let mut canvas: RgbaImage = RgbaImage::from_pixel(CANVAS_W, CANVAS_H, Rgba([0, 0, 0, 0]));
let logo_w = logo_rgba.width();
let logo_h = logo_rgba.height();
// Horizontal center, top-margin from the top. Saturating math
// means a logo wider than CANVAS_W (shouldn't happen after the
// downscale above, but defensive) just sits flush-left.
let off_x = CANVAS_W.saturating_sub(logo_w) / 2;
let off_y = LOGO_TOP_MARGIN.min(CANVAS_H.saturating_sub(logo_h));
image::imageops::overlay(&mut canvas, &logo_rgba, off_x.into(), off_y.into());
let mut out = Vec::with_capacity(64 * 1024);
DynamicImage::ImageRgba8(canvas).write_to(&mut Cursor::new(&mut out), ImageFormat::Png)?;
Ok(out)
}
fn downscale_to_fit(img: DynamicImage, max_w: u32, max_h: u32) -> DynamicImage {
let (w, h) = (img.width(), img.height());
if w <= max_w && h <= max_h {
return img;
}
// Preserve aspect ratio. `resize` clamps to the smaller of the
// two scale factors so we never overshoot the bounding box.
img.resize(max_w, max_h, FilterType::Lanczos3)
}
#[cfg(test)]
mod tests {
use super::*;
use image::{ImageBuffer, Rgb};
fn solid_png(w: u32, h: u32, rgb: [u8; 3]) -> Vec<u8> {
let img: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_pixel(w, h, Rgb(rgb));
let mut out = Vec::with_capacity(4096);
DynamicImage::ImageRgb8(img)
.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
.unwrap();
out
}
#[test]
fn compose_emits_canvas_sized_png() {
let src = solid_png(120, 60, [200, 50, 50]);
let out = compose_pxe_logo(&src).unwrap();
// Round-trip the output and confirm dimensions.
let img = image::load_from_memory(&out).unwrap();
assert_eq!(img.width(), CANVAS_W);
assert_eq!(img.height(), CANVAS_H);
}
#[test]
fn small_logo_centered_at_top_margin() {
let src = solid_png(100, 40, [10, 200, 10]);
let out = compose_pxe_logo(&src).unwrap();
let canvas = image::load_from_memory(&out).unwrap().to_rgba8();
// Pixel just inside the logo box should match the source color
// (alpha=255). Pixel near a far corner of the canvas should be
// the transparent background.
let cx = (CANVAS_W - 100) / 2;
let cy = LOGO_TOP_MARGIN;
let inside = canvas.get_pixel(cx + 10, cy + 10);
assert_eq!(inside.0[3], 255, "logo pixel should be opaque");
assert!(inside.0[0] < 100 && inside.0[1] > 100 && inside.0[2] < 100, "color mismatch: {inside:?}");
let corner = canvas.get_pixel(CANVAS_W - 1, CANVAS_H - 1);
assert_eq!(corner.0[3], 0, "canvas corner should be transparent");
}
#[test]
fn oversize_logo_is_downscaled_to_bounding_box() {
// 4000×800 image — bigger than LOGO_MAX_W and LOGO_MAX_H in
// both axes. After downscale the output must fit; we re-decode
// the canvas, count non-transparent pixels, and confirm none
// sit outside the expected band.
let src = solid_png(4000, 800, [50, 50, 200]);
let out = compose_pxe_logo(&src).unwrap();
let canvas = image::load_from_memory(&out).unwrap().to_rgba8();
// Span row at the top margin should have non-transparent
// pixels somewhere; rows past the LOGO_TOP_MARGIN + LOGO_MAX_H
// should be entirely transparent.
let bottom_band_y = LOGO_TOP_MARGIN + LOGO_MAX_H + 10;
for x in 0..CANVAS_W {
let p = canvas.get_pixel(x, bottom_band_y);
assert_eq!(p.0[3], 0, "row {bottom_band_y} should be transparent at x={x}");
}
}
#[test]
fn unsupported_bytes_returns_error_not_panic() {
let r = compose_pxe_logo(b"\xde\xad\xbe\xef not an image");
assert!(r.is_err());
}
}
+23 -86
View File
@@ -20,16 +20,16 @@
//! - SMB2 minimum (no SMB1 legacy, not needed for WinPE).
//! - Bound to 0.0.0.0:445; operator MUST put this on a trusted install
//! VLAN — guest SMB is not for the general internet.
//! - smbd runs as the same non-root uid as openpxe (10001).
//! - smbd runs as the same non-root uid as pxeforge (10001).
//! - If `smbd` isn't on PATH (e.g. lightweight container build without
//! 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())
@@ -102,7 +100,7 @@ impl SmbManager {
conf,
"\n[{name}]\n\
path = {}\n\
comment = OpenPXE Windows install media ({name})\n\
comment = PXEForge Windows install media ({name})\n\
read only = yes\n\
guest ok = yes\n\
guest only = yes\n\
@@ -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())
@@ -157,13 +152,11 @@ impl SmbManager {
*g = Some(c);
let s = SmbState::Running { pid, shares };
*self.state.lock() = s.clone();
tracing::info!(target: "openpxe::smb", pid, shares=?self.state.lock(), "smbd started");
tracing::info!(target: "pxeforge::smb", pid, shares=?self.state.lock(), "smbd started");
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,14 +245,10 @@ 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");
tracing::debug!(target: "pxeforge::smb", slug, "ISO already extracted, skipping");
return Ok(target);
}
std::fs::create_dir_all(&target)?;
@@ -296,11 +263,9 @@ 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",
target: "pxeforge::smb",
stderr=%String::from_utf8_lossy(&out.stderr),
"7z extract failed, trying bsdtar"
);
@@ -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
}
@@ -354,14 +315,12 @@ mod tests {
fn start_without_smbd_reports_missing() {
// Drop smbd from PATH for this test.
let saved = std::env::var_os("PATH");
std::env::set_var("PATH", "/usr/nowhere-openpxe-test");
std::env::set_var("PATH", "/usr/nowhere-pxeforge-test");
let dir = tempdir().unwrap();
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}"
);
}
}
}
+12 -339
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 pxeforge_core::{Error, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
@@ -31,30 +31,6 @@ pub enum IsoSource {
},
}
/// Where the ISO lands in the PXE menu hierarchy.
///
/// Auto-detected family (Debian, Windows, …) still drives BIOS/UEFI
/// behaviour and per-entry boot args, but the *menu placement* is
/// operator-controlled — an operator who's uploaded a TinyCore live ISO
/// to use as a recovery shim, or a SystemRescue image, can flip its
/// category to `Tools` so it lands next to memtest/shell instead of
/// under Linux Installers.
///
/// Old `meta.json` files without this field deserialize as `Os`, which
/// matches v0.4.1 behaviour (everything goes under OS Installers).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IsoCategory {
/// "OS Installer" — routed via the auto-detected family into the
/// Linux / Windows installer submenus.
#[default]
Os,
/// "Tool" — surfaced under the Tools menu next to memtest, shell,
/// NIC info, etc. Family detection still decides BIOS/UEFI vs
/// wimboot vs sanboot at boot time.
Tools,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IsoMeta {
/// Stable slug used in URLs (derived from the uploaded filename).
@@ -72,29 +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>,
/// Where the ISO sits in the PXE menu hierarchy — operator-controlled,
/// not driven by family detection. Defaults to [`IsoCategory::Os`].
#[serde(default)]
pub category: IsoCategory,
}
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 {
@@ -141,8 +94,6 @@ impl UploadHandle {
introspection,
boot_entries,
source: IsoSource::Local,
password_hash: None,
category: IsoCategory::default(),
};
store.persist_meta(&meta).await?;
store.insert(meta.clone());
@@ -199,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())
@@ -245,9 +194,6 @@ impl IsoStore {
return Err(Error::Invalid(format!("iso '{id}' already exists")));
}
let partial_path = self.iso_dir.join(format!("{id}.partial"));
if partial_path.exists() {
return Err(Error::Invalid(format!("iso '{id}' is already uploading")));
}
let file = tokio::fs::File::create(&partial_path).await?;
Ok(UploadHandle {
id,
@@ -310,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);
@@ -346,8 +289,6 @@ impl IsoStore {
introspection,
boot_entries,
source,
password_hash: None,
category: IsoCategory::default(),
};
self.inner.write().isos.insert(id, meta);
}
@@ -357,138 +298,10 @@ 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),
);
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(())
}
/// Flip an ISO's menu category. Persists to `meta.json` for local
/// ISOs; NFS-sourced ISOs keep the change in memory only (the next
/// re-scan would overwrite it anyway).
pub async fn set_category(&self, id: &str, category: IsoCategory) -> Result<IsoMeta> {
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.category = category;
m.clone()
};
if matches!(updated.source, IsoSource::Local) {
self.persist_meta(&updated).await?;
}
Ok(updated)
}
/// Absolute path to the directory holding local ISO uploads. Used
/// by the HTTP layer for the disk-space endpoint — the volume that
/// hosts this directory is what runs out of room first.
#[must_use]
pub fn iso_dir(&self) -> PathBuf {
self.iso_dir.as_path().to_path_buf()
}
/// `(total_bytes, available_bytes)` for the filesystem hosting the
/// ISO directory. Returns `None` if `statvfs` fails (read-only
/// filesystem with no quota, mount disappeared, …) — callers
/// should treat that as "unknown" rather than zero.
///
/// Lives here rather than the HTTP crate because `http-api`'s
/// `#![forbid(unsafe_code)]` rules out the libc FFI directly, and
/// because this is naturally an `IsoStore` question — the volume
/// of interest is whatever's hosting the iso dir.
#[must_use]
pub fn disk_usage(&self) -> Option<(u64, u64)> {
disk_usage_for(self.iso_dir.as_path())
}
/// 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()))
}
}
/// Resolve `(total, available)` bytes for the filesystem hosting `path`.
/// Returns `None` if `statvfs` fails.
#[allow(unsafe_code)]
fn disk_usage_for(path: &std::path::Path) -> Option<(u64, u64)> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let c = CString::new(path.as_os_str().as_bytes()).ok()?;
// SAFETY: `statvfs` is repr(C); a zeroed value is a valid initial
// state per POSIX. The FFI call writes every field we then read.
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
// SAFETY: `c` is a NUL-terminated C string pointing into a stack
// CString that outlives this call; `&mut stat` is a unique aligned
// pointer to a stack-local `statvfs`. The kernel writes through
// it but does not retain the pointer past return.
let rc = unsafe { libc::statvfs(c.as_ptr(), &raw mut stat) };
if rc != 0 {
return None;
}
// Use f_frsize (fundamental block size). f_bsize is "preferred I/O
// block" and doesn't always match the unit f_blocks is denominated
// in — on some BSDs it would over-report by a factor of 8.
let frsize = stat.f_frsize as u64;
let total = stat.f_blocks as u64 * frsize;
let avail = stat.f_bavail as u64 * frsize;
Some((total, avail))
}
fn slugify(filename: &str) -> String {
@@ -533,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
@@ -560,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 },
}]
}
_ => {
@@ -584,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") },
}]
}
}
@@ -596,17 +394,8 @@ fn linux_cmdline(family: DistroFamily, id: &str) -> String {
// The HTTP layer resolves `${base-url}` at render time.
let iso_url = format!("${{base-url}}/iso/{id}.iso");
match family {
// VMware-UEFI fix (v0.4.5, matching Bootimus v0.1.67's Casper
// patch): drop `netboot=url url=… ---` in favour of the
// canonical Casper option `iso-url=` and add `ds=nocloud` so
// cloud-init / subiquity (live-server) doesn't stall waiting on
// a metadata datasource that doesn't exist in PXE. Without
// `ds=nocloud`, Ubuntu live-server / Mint / Pop!_OS / elementary
// ISOs would boot fine on bare-metal UEFI but hang at "cloud-init
// running" on VMware-UEFI guests because the vmxnet3 driver's
// late-init upsets cloud-init's network probe.
DistroFamily::DebianUbuntu => format!(
"boot=casper initrd=initrd ds=nocloud ip=dhcp iso-url={iso_url}"
"boot=casper netboot=url url={iso_url} ip=dhcp ---"
),
DistroFamily::RhelFedora => format!(
"inst.repo={iso_url} inst.stage2={iso_url} ip=dhcp"
@@ -627,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() {
@@ -640,118 +427,4 @@ mod tests {
// If a path sneaks in, file_stem strips the directory — OK, not a hazard.
assert_eq!(slugify("/etc/passwd"), "passwd");
}
#[test]
fn casper_cmdline_vmware_uefi_safe() {
// v0.4.5 regression guard: the Debian/Ubuntu cmdline must use
// the canonical Casper `iso-url=` option and include
// `ds=nocloud` so VMware-UEFI guests don't hang at "cloud-init
// running" waiting on a metadata datasource that PXE can't
// provide. The legacy `netboot=url url=… ---` form is gone for
// good.
let s = linux_cmdline(DistroFamily::DebianUbuntu, "ubuntu-24-04");
assert!(s.contains("boot=casper"), "{s}");
assert!(s.contains("iso-url=${base-url}/iso/ubuntu-24-04.iso"), "{s}");
assert!(s.contains("ds=nocloud"), "{s}");
assert!(s.contains("ip=dhcp"), "{s}");
assert!(!s.contains("netboot=url"), "legacy option leaked: {s}");
assert!(!s.contains(" --- "), "stray ---: {s}");
}
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,
category: IsoCategory::default(),
}
}
#[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 begin_upload_rejects_existing_partial_file() {
let dir = tempdir().unwrap();
let store = IsoStore::new(dir.path().to_path_buf());
store.ensure_dirs().await.unwrap();
tokio::fs::write(dir.path().join("ubuntu.partial"), b"in-flight")
.await
.unwrap();
let r = store.begin_upload("ubuntu.iso").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());
}
}
+14 -51
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
}
@@ -176,7 +160,7 @@ fn render_startnet(host: &str, share: &str) -> String {
// Bootimus v0.1.58 lesson: surface `net use` errors instead of
// tight-looping on a blind retry. We retry but log every miss.
s.push_str("@echo off\r\n");
s.push_str("echo OpenPXE WinPE bootstrap\r\n");
s.push_str("echo PXEForge WinPE bootstrap\r\n");
s.push_str("wpeinit\r\n");
// v0.1.58: explicitly start Workstation before mapping the share —
// `net use` otherwise lazily inits SMB-client and races wpeinit.
@@ -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
@@ -1,5 +1,5 @@
[package]
name = "openpxe"
name = "pxeforge"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -10,16 +10,16 @@ description = "Container-native PXE boot server — a lightweight Rust clone of
workspace = true
[[bin]]
name = "openpxe"
name = "pxeforge"
path = "src/main.rs"
[dependencies]
openpxe-core.workspace = true
openpxe-dhcp-proxy.workspace = true
openpxe-tftp.workspace = true
openpxe-http-api.workspace = true
openpxe-iso-store.workspace = true
openpxe-ipxe-assets.workspace = true
pxeforge-core.workspace = true
pxeforge-dhcp-proxy.workspace = true
pxeforge-tftp.workspace = true
pxeforge-http-api.workspace = true
pxeforge-iso-store.workspace = true
pxeforge-ipxe-assets.workspace = true
tokio.workspace = true
axum.workspace = true
tracing.workspace = true
@@ -1,27 +1,27 @@
//! OpenPXE entry point. Wires the three protocol servers (DHCP proxy,
//! PXEForge entry point. Wires the three protocol servers (DHCP proxy,
//! TFTP, HTTP) to the shared ISO store and client registry, then runs
//! them concurrently.
use clap::{Parser, Subcommand};
use openpxe_core::{
ClientRegistry, Config, DeploymentQueue, DhcpMode, HostBindings, LogBus, LogBusLayer, Metrics,
use pxeforge_core::{
ClientRegistry, Config, DhcpMode, GateQueue, 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 openpxe_tftp::TftpServer;
use pxeforge_dhcp_proxy::DhcpProxyServer;
use pxeforge_http_api::{build_router, AppState};
use pxeforge_iso_store::{IsoStore, NfsManager, SmbManager};
use std::sync::Arc;
use pxeforge_tftp::TftpServer;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::io::AsyncReadExt;
#[derive(Debug, Parser)]
#[command(name = "openpxe", about = "Container-native PXE boot server", version)]
#[command(name = "pxeforge", about = "Container-native PXE boot server", version)]
struct Cli {
/// Path to a TOML config file. All fields have sensible defaults and can
/// also be overridden with env vars (OPENPXE_*).
#[arg(long, env = "OPENPXE_CONFIG")]
/// also be overridden with env vars (PXEFORGE_*).
#[arg(long, env = "PXEFORGE_CONFIG")]
config: Option<PathBuf>,
#[command(subcommand)]
@@ -38,8 +38,8 @@ enum Command {
/// Example:
/// docker run --rm \
/// -v /my/isos:/seed:ro \
/// -v openpxe-data:/var/lib/openpxe/isos \
/// openpxe:0.4.1 seed --from /seed
/// -v pxeforge-data:/var/lib/pxeforge/isos \
/// pxeforge:0.1.0 seed --from /seed
Seed {
/// Source directory containing one or more `.iso` files.
#[arg(long)]
@@ -70,7 +70,7 @@ async fn main() -> anyhow::Result<()> {
return run_command(cmd, config).await;
}
openpxe_ipxe_assets::log_availability();
pxeforge_ipxe_assets::log_availability();
let our_ip = match config.server.public_ip {
Some(ip) => {
@@ -88,7 +88,7 @@ async fn main() -> anyhow::Result<()> {
// message instead of serving a broken deployment.
anyhow::bail!(
"could not detect a non-loopback IPv4 address for this host. \
Set OPENPXE_PUBLIC_IP=<your-ip> (e.g. `-e OPENPXE_PUBLIC_IP=10.0.0.5` \
Set PXEFORGE_PUBLIC_IP=<your-ip> (e.g. `-e PXEFORGE_PUBLIC_IP=10.0.0.5` \
in docker, or the env block in OpenShift Deployment) to advertise \
a specific IP to PXE clients."
);
@@ -100,14 +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 = GateQueue::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 branding = openpxe_core::BrandingStore::load_or_default(&config.paths.work_dir);
let admin = openpxe_core::AdminStore::load_or_default(&config.paths.work_dir);
let sso = openpxe_core::SsoStore::load_or_default(&config.paths.work_dir);
let sessions = openpxe_http_api::auth::SessionStore::default();
let metrics = Metrics::new();
// Build the SMB manager unconditionally — it starts/stops on the
@@ -125,7 +120,7 @@ async fn main() -> anyhow::Result<()> {
let nfs = NfsManager::new(&config.paths.work_dir, iso_store.clone());
iso_store.set_nfs_root(nfs.mount_root());
if let Err(e) = nfs.load_and_remount().await {
tracing::warn!(target: "openpxe::nfs", "could not reload NFS mounts: {e}");
tracing::warn!(target: "pxeforge::nfs", "could not reload NFS mounts: {e}");
}
// Sniff network details for the Network tab. None of these are
@@ -134,7 +129,7 @@ async fn main() -> anyhow::Result<()> {
// own gateway.
let net = detect_network_info(our_ip);
tracing::info!(
target: "openpxe::net",
target: "pxeforge::net",
nic = %net.nic_name, mask = %net.subnet_mask, gateway = %net.gateway,
"network info"
);
@@ -143,17 +138,11 @@ async fn main() -> anyhow::Result<()> {
iso_store: iso_store.clone(),
clients: clients.clone(),
settings: settings.clone(),
queue: queue.clone(),
gates: gates.clone(),
hosts: hosts.clone(),
boot_log: boot_log.clone(),
branding: branding.clone(),
admin: admin.clone(),
sessions: sessions.clone(),
sso: sso.clone(),
metrics: metrics.clone(),
smb: Some(smb.clone()),
nfs: nfs.clone(),
uploads: openpxe_http_api::uploads::UploadSessions::default(),
log_bus: log_bus.clone(),
started_at: time::OffsetDateTime::now_utc(),
public_base_url: public_base_url.clone(),
@@ -166,16 +155,8 @@ async fn main() -> anyhow::Result<()> {
let router = build_router(state);
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?;
tracing::info!(target: "pxeforge::http", "HTTP listening on {http_addr}");
axum::serve(listener, router).await?;
Ok::<_, anyhow::Error>(())
});
@@ -201,7 +182,7 @@ async fn main() -> anyhow::Result<()> {
tokio::spawn(s.run())
}
DhcpMode::Disabled => {
tracing::info!(target: "openpxe::dhcp", "DHCP disabled — external DHCP must set next-server + filename");
tracing::info!(target: "pxeforge::dhcp", "DHCP disabled — external DHCP must set next-server + filename");
tokio::spawn(async { futures_forever().await })
}
};
@@ -229,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?;
@@ -241,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
@@ -254,18 +226,12 @@ 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,
Err(openpxe_core::Error::Invalid(e)) => {
Err(pxeforge_core::Error::Invalid(e)) => {
eprintln!(" skip: {e}");
skipped += 1;
continue;
@@ -276,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(())
}
@@ -300,7 +258,7 @@ async fn seed_from_dir(
/// detection fails — callers should fail startup rather than silently using
/// 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.
/// `PXEFORGE_PUBLIC_IP` explicitly.
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`
@@ -336,14 +294,15 @@ 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>) {
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
let filter = EnvFilter::try_from_env("OPENPXE_LOG")
.unwrap_or_else(|_| EnvFilter::new("info,openpxe=debug"));
let filter = EnvFilter::try_from_env("PXEFORGE_LOG")
.unwrap_or_else(|_| EnvFilter::new("info,pxeforge=debug"));
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer().with_target(true))
@@ -369,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()) {
@@ -397,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();
@@ -421,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,
+3 -3
View File
@@ -1,5 +1,5 @@
[package]
name = "openpxe-tftp"
name = "pxeforge-tftp"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -10,8 +10,8 @@ description = "TFTP server (RFC 1350/2347/2348/2349/7440) for iPXE chainload"
workspace = true
[dependencies]
openpxe-core.workspace = true
openpxe-ipxe-assets.workspace = true
pxeforge-core.workspace = true
pxeforge-ipxe-assets.workspace = true
tokio.workspace = true
socket2.workspace = true
tracing.workspace = true
+28 -62
View File
@@ -7,12 +7,12 @@
//! `tftpd`/`in.tftpd` works and is why TFTP is awkward behind stateful NAT:
//! the ephemeral ports must be reachable from the client.
//!
//! We only serve files from `openpxe_ipxe_assets::asset_bytes` — that is,
//! We only serve files from `pxeforge_ipxe_assets::asset_bytes` — that is,
//! the bundled iPXE binaries and wimboot. No filesystem is ever opened, so
//! `../` path traversal attempts simply return ENOENT.
use openpxe_core::{ClientEvent, ClientRegistry};
use openpxe_ipxe_assets::asset_bytes;
use pxeforge_core::{ClientEvent, ClientRegistry};
use pxeforge_ipxe_assets::asset_bytes;
use socket2::{Domain, Protocol, Socket, Type};
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
@@ -35,7 +35,7 @@ pub struct TftpServer {
bind: IpAddr,
port: u16,
clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
metrics: pxeforge_core::Metrics,
}
impl TftpServer {
@@ -43,19 +43,14 @@ impl TftpServer {
bind: IpAddr,
port: u16,
clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
metrics: pxeforge_core::Metrics,
) -> Self {
Self {
bind,
port,
clients,
metrics,
}
Self { bind, port, clients, metrics }
}
pub async fn run(self) -> anyhow::Result<()> {
let sock = bind_udp(self.bind, self.port)?;
tracing::info!(target: "openpxe::tftp", "TFTP listening on {}:{}", self.bind, self.port);
tracing::info!(target: "pxeforge::tftp", "TFTP listening on {}:{}", self.bind, self.port);
let clients = self.clients.clone();
let metrics = self.metrics.clone();
let mut buf = vec![0u8; 2048];
@@ -63,7 +58,7 @@ impl TftpServer {
let (n, from) = match sock.recv_from(&mut buf).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(target: "openpxe::tftp", "recv error: {e}");
tracing::warn!(target: "pxeforge::tftp", "recv error: {e}");
continue;
}
};
@@ -74,7 +69,7 @@ impl TftpServer {
tokio::spawn(async move {
if let Err(e) = handle_rrq(data, from, bind_ip, clients, metrics.clone()).await {
metrics.record_tftp_err();
tracing::warn!(target: "openpxe::tftp", peer=%from, "handler error: {e}");
tracing::warn!(target: "pxeforge::tftp", peer=%from, "handler error: {e}");
}
});
}
@@ -86,34 +81,30 @@ async fn handle_rrq(
peer: SocketAddr,
bind_ip: IpAddr,
clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
metrics: pxeforge_core::Metrics,
) -> anyhow::Result<()> {
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)?;
let Some(file_bytes) = asset_bytes(&filename) else {
let _ = send_error(&sock, peer, ERR_FILE_NOT_FOUND, "no such file").await;
tracing::info!(target: "openpxe::tftp", peer=%peer, file=%filename, "404");
tracing::info!(target: "pxeforge::tftp", peer=%peer, file=%filename, "404");
clients.record(
&peer.ip().to_string(),
Some(peer.ip()),
None,
ClientEvent::TftpRead {
file: filename.clone(),
},
ClientEvent::TftpRead { file: filename.clone() },
);
return Ok(());
};
tracing::info!(
target: "openpxe::tftp",
target: "pxeforge::tftp",
peer=%peer, file=%filename, size=file_bytes.len(),
"serving"
);
@@ -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);
@@ -213,7 +200,7 @@ async fn handle_rrq(
tries += 1;
if tries > 5 {
tracing::warn!(
target: "openpxe::tftp",
target: "pxeforge::tftp",
peer=%peer, last_block=last_block_in_window,
"timeout after {tries} retries, aborting transfer"
);
@@ -254,7 +241,7 @@ async fn handle_rrq(
let _ = tokio::time::timeout(Duration::from_secs(3), recv_ack(&sock, peer)).await;
}
tracing::debug!(target: "openpxe::tftp", peer=%peer, bytes=total, "transfer complete");
tracing::debug!(target: "pxeforge::tftp", peer=%peer, bytes=total, "transfer complete");
metrics.record_tftp_ok(total as u64);
Ok(())
}
@@ -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;
@@ -487,7 +453,7 @@ mod tests {
assert_eq!(p, vec![(65534, 1024), (65535, 1024)]);
let p2 = plan_window(2048, 2048, 1024, 2, 0);
assert!(p2.is_empty()); // nothing past EOF
// And a cross-boundary case:
// And a cross-boundary case:
let p3 = plan_window(3072, 0, 1024, 3, 65535);
assert_eq!(p3, vec![(65535, 1024), (0, 1024), (1, 1024)]);
}
+2 -2
View File
@@ -1,10 +1,10 @@
[package]
name = "openpxe-webui"
name = "pxeforge-webui"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "Embedded single-file web UI for OpenPXE"
description = "Embedded single-file web UI for PXEForge"
[lints]
workspace = true
+81
View File
@@ -0,0 +1,81 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" fill="none">
<title>PXEForge — forging</title>
<defs>
<linearGradient id="afBody" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#aab3c2"/>
<stop offset="55%" stop-color="#7d8696"/>
<stop offset="100%" stop-color="#525a6b"/>
</linearGradient>
<linearGradient id="afFace" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#cdd5e1"/>
<stop offset="100%" stop-color="#9aa3b3"/>
</linearGradient>
<linearGradient id="afBase" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#3b4252"/>
<stop offset="100%" stop-color="#252a36"/>
</linearGradient>
<radialGradient id="afSpark" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#fff5b8" stop-opacity="1"/>
<stop offset="40%" stop-color="#ff9a3a" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#ff5a18" stop-opacity="0"/>
</radialGradient>
<radialGradient id="afEmber" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#ffd47a" stop-opacity="1"/>
<stop offset="100%" stop-color="#ff7322" stop-opacity="0"/>
</radialGradient>
</defs>
<!-- Anvil shifted down so sparks have room to rise above -->
<g transform="translate(0,40)">
<!-- Horn + face -->
<path d="M14 36 L72 30 L162 30 L162 46 L72 46 Z"
fill="url(#afFace)" stroke="#1d2330" stroke-width="2.4" stroke-linejoin="round"/>
<!-- Body / waist -->
<path d="M70 46 L160 46 L142 70 L88 70 Z"
fill="url(#afBody)" stroke="#1d2330" stroke-width="2.4" stroke-linejoin="round"/>
<!-- Pillar -->
<rect x="92" y="70" width="46" height="26" fill="url(#afBody)"
stroke="#1d2330" stroke-width="2.4"/>
<!-- Base -->
<path d="M62 96 L168 96 L160 110 L70 110 Z"
fill="url(#afBase)" stroke="#0d1018" stroke-width="2.4" stroke-linejoin="round"/>
<line x1="74" y1="34" x2="158" y2="34" stroke="#e6ecf5" stroke-width="1.2" opacity="0.7"/>
<!-- Soft underglow on top face where the sparks land -->
<ellipse cx="115" cy="33" rx="42" ry="6" fill="url(#afEmber)" opacity="0.55">
<animate attributeName="opacity" values="0.35;0.7;0.35"
dur="1.6s" repeatCount="indefinite"/>
</ellipse>
</g>
<!-- Sparks. SMIL animations only — no JS, no CSS needed. Each spark
rises, fades, restarts at a staggered delay for an organic feel. -->
<g class="sparks">
<circle cx="116" cy="62" r="3.4" fill="url(#afSpark)" opacity="0">
<animate attributeName="cy" from="62" to="14" dur="1.4s" repeatCount="indefinite"/>
<animate attributeName="cx" values="116;112;120;116" dur="1.4s" repeatCount="indefinite"/>
<animate attributeName="r" values="2;3.6;1.4" dur="1.4s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;0" dur="1.4s" repeatCount="indefinite"/>
</circle>
<circle cx="105" cy="62" r="2.4" fill="url(#afSpark)" opacity="0">
<animate attributeName="cy" from="62" to="22" dur="1.7s" begin="0.25s" repeatCount="indefinite"/>
<animate attributeName="cx" values="105;101;108;104" dur="1.7s" begin="0.25s" repeatCount="indefinite"/>
<animate attributeName="r" values="1.6;2.8;1" dur="1.7s" begin="0.25s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;0" dur="1.7s" begin="0.25s" repeatCount="indefinite"/>
</circle>
<circle cx="125" cy="62" r="2.8" fill="url(#afSpark)" opacity="0">
<animate attributeName="cy" from="62" to="6" dur="1.9s" begin="0.55s" repeatCount="indefinite"/>
<animate attributeName="cx" values="125;130;121;127" dur="1.9s" begin="0.55s" repeatCount="indefinite"/>
<animate attributeName="r" values="1.8;3.2;1.2" dur="1.9s" begin="0.55s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;0" dur="1.9s" begin="0.55s" repeatCount="indefinite"/>
</circle>
<circle cx="113" cy="62" r="2" fill="url(#afEmber)" opacity="0">
<animate attributeName="cy" from="62" to="32" dur="1.2s" begin="0.9s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;0.9;0" dur="1.2s" begin="0.9s" repeatCount="indefinite"/>
</circle>
<circle cx="132" cy="62" r="2.2" fill="url(#afSpark)" opacity="0">
<animate attributeName="cy" from="62" to="20" dur="1.5s" begin="1.2s" repeatCount="indefinite"/>
<animate attributeName="cx" values="132;138;128" dur="1.5s" begin="1.2s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;0" dur="1.5s" begin="1.2s" repeatCount="indefinite"/>
</circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

+74 -327
View File
@@ -1,4 +1,4 @@
/* OpenPXE web UI Netbox-style minimal layout, fully offline.
/* PXEForge web UI Netbox-style minimal layout, fully offline.
*
* Theme tokens live on `:root` (dark default) and `:root[data-theme=light]`.
* Both palettes share variable *names*, so component CSS uses
@@ -8,26 +8,23 @@
* CSS lands). */
:root {
/* Jet-black dark palette (default). Modelled on Netbox Labs's
near-black product chrome, with surfaces stepping subtly upward
rather than the previous blue-tinted ramp, so the UI reads as a
genuine "dark" rather than "dim navy". */
--bg: #030303;
--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: #050505;
--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;
@@ -41,7 +38,7 @@
consistency. Designed against Netbox Labs's reference screenshot:
near-white surfaces, soft grey dividers, dark text. */
--bg: #f6f8fb;
--bg-panel: #fbfcfe;
--bg-panel: #ffffff;
--bg-panel-2: #f0f3f8;
--bg-elev: #e6ebf2;
--fg: #1c2330;
@@ -54,11 +51,7 @@
--ok: #1f9b54;
--border: #d8dde6;
--border-soft: #e7eaf0;
/* Light-mode terminal: the pane background and chrome track the rest
of the light theme. Per-level text colours below recolour-on-light
so log lines stay readable on a pale background previously the
terminal was locked to dark and looked like a stuck panel. */
--terminal-bg: #ffffff;
--terminal-bg: #0d1219; /* terminal stays dark even in light mode */
--shadow-card: 0 1px 0 rgba(0,0,0,0.02), 0 6px 18px rgba(20,28,52,0.06);
}
@@ -91,23 +84,14 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
}
/* The brand block sits flush with the topbar so the sidebar+topbar reads
as one continuous bar across the top of the app, rather than a chunky
2-line logo block plus a separate (smaller-typeface) page title. The
height/border-bottom match the topbar exactly so the divider runs
straight across without a step. */
.sidebar .brand {
display: flex; align-items: center; gap: 12px;
padding: 0 18px;
height: var(--topbar-h);
padding: 14px 18px;
border-bottom: 1px solid var(--border);
}
.sidebar .brand img { width: 26px; height: 26px; flex: none; }
.sidebar .brand strong {
font-size: 15px; font-weight: 600;
letter-spacing: 0.2px;
color: var(--fg);
}
.sidebar .brand img { width: 40px; height: auto; }
.sidebar .brand strong { font-size: 16px; letter-spacing: 0.4px; }
.sidebar .brand .sub { color: var(--fg-dim); font-size: 11px; }
.sidebar nav { padding: 10px 0; flex: 1; overflow-y: auto; }
.sidebar nav a {
display: flex; align-items: center; gap: 10px;
@@ -129,40 +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; }
/* Persistent backend identity. Sits below the advertised URL so even
when an operator has uploaded their own logo, "what is this" stays
answerable from the bottom-left of every page. */
.sidebar .footer .footer-version {
margin-top: 8px; padding-top: 8px;
border-top: 1px dashed var(--border-soft);
color: var(--fg-dim);
font-variant-numeric: tabular-nums;
letter-spacing: 0.2px;
}
.sidebar .footer code { background: transparent; color: var(--fg-dim); padding: 0; }
/* ── Top bar ───────────────────────────────────────────────────────── */
@@ -276,7 +230,7 @@ button, .btn {
cursor: pointer;
transition: background 0.12s ease;
}
button:hover, .btn:hover { background: var(--accent-dim); color: #f4fffd; }
button:hover, .btn:hover { background: var(--accent-dim); color: #fff; }
button.ghost { background: transparent; color: var(--fg); border: 1px solid var(--border); }
button.ghost:hover { background: var(--bg-panel-2); color: var(--fg); }
button.danger { background: transparent; color: var(--err); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); }
@@ -287,26 +241,16 @@ label.field {
}
label.field .name { color: var(--fg-dim); font-size: 12px; }
label.field .hint { color: var(--fg-dimmer); font-size: 11px; }
/* All single-line inputs share one chrome rule. Pre-v0.4.6 we only
styled type=text/number, which left type=password fields rendering
with the default browser look visibly off vs adjacent text fields
in the Account card. The negation list keeps `type=checkbox`,
`type=file`, and `type=range` (none of which we use inside
`label.field`) from picking up the padded-box look. */
label.field input:not([type="checkbox"]):not([type="file"]):not([type="range"]),
label.field input[type="text"],
label.field input[type="number"],
label.field select,
label.field textarea {
width: 100%; background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 7px 10px; font: inherit;
/* iOS/Safari shrinks password-field text by default; clamp it so
the password input matches the username input's metrics. */
font-size: 14px; line-height: 1.4;
box-shadow: none; -webkit-appearance: none; appearance: none;
}
label.field input:focus, label.field select:focus, label.field textarea:focus {
outline: none; border-color: var(--accent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
}
label.check {
display: flex; gap: 10px; align-items: center;
@@ -335,34 +279,35 @@ label.check input { accent-color: var(--accent); }
.progress.active { display: block; }
.progress .bar { height: 100%; width: 0%; background: var(--accent); transition: width .25s; }
/* Imaging progress widget
Animated brand mark paired with a horizontal progress bar; surfaces
on Dashboard and the Queue tab. */
.queue-progress {
/* Forge progress widget
Anvil-with-sparks animation paired with a horizontal progress bar.
Used on the Forge Gate tab to give a sense of the "in-flight"
imaging count without having to read a number. */
.forge-progress {
display: flex; align-items: center; gap: 16px;
padding: 16px;
}
.queue-progress .mark {
width: 56px; height: 56px; flex: none; border-radius: 50%;
background: url("/assets/loader.svg") no-repeat center / contain;
filter: drop-shadow(0 0 16px rgba(255, 255, 255, 0.10));
.forge-progress .anvil {
width: 64px; height: 64px; flex: none;
background: url("/assets/anvil-forge.svg") no-repeat center / contain;
filter: drop-shadow(0 0 14px color-mix(in srgb, var(--warn) 40%, transparent));
}
.queue-progress .info { flex: 1; min-width: 0; }
.queue-progress .info .label {
.forge-progress .info { flex: 1; min-width: 0; }
.forge-progress .info .label {
font-size: 12.5px; color: var(--fg-dim); margin-bottom: 6px;
}
.queue-progress .bar-track {
.forge-progress .bar-track {
height: 8px; background: var(--bg-elev); border-radius: 4px;
overflow: hidden; position: relative;
}
.queue-progress .bar-fill {
.forge-progress .bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-dim), var(--accent));
background: linear-gradient(90deg, var(--warn), var(--accent));
width: 0%;
transition: width 0.4s ease;
position: relative;
}
.queue-progress .bar-fill::after {
.forge-progress .bar-fill::after {
/* Subtle moving sheen so the bar feels alive even at 0% movement. */
content: ""; position: absolute; inset: 0;
background: linear-gradient(
@@ -370,24 +315,24 @@ label.check input { accent-color: var(--accent); }
rgba(255,255,255,0) 0%,
rgba(255,255,255,0.18) 50%,
rgba(255,255,255,0) 100%);
animation: queue-sheen 1.6s linear infinite;
animation: forge-sheen 1.6s linear infinite;
}
@keyframes queue-sheen {
@keyframes forge-sheen {
from { transform: translateX(-100%); }
to { transform: translateX(100%); }
}
.queue-progress.idle .mark { filter: grayscale(0.85) opacity(0.55); }
.queue-progress.idle .bar-fill::after { animation: none; }
.forge-progress.idle .anvil { filter: none; opacity: 0.45; }
.forge-progress.idle .bar-fill::after { animation: none; }
/* ── Page-load loader ────────────────────────────────────────────── */
/* ── Page-load anvil ──────────────────────────────────────────────── */
.loader {
display: flex; flex-direction: column; align-items: center; gap: 12px;
padding: 40px 20px;
color: var(--fg-dim);
}
.loader .mark {
width: 96px; height: 96px;
background: url("/assets/loader.svg") no-repeat center / contain;
.loader .anvil {
width: 110px; height: 110px;
background: url("/assets/anvil-forge.svg") no-repeat center / contain;
}
/* ── Top bar readiness chip ──────────────────────────────────────── */
@@ -431,39 +376,27 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
.dot.err { background: var(--err); }
.dot.warn { background: var(--warn); }
/* Inline form rows. The default is a 4-column grid sized for the
Account card's "Current / New username / New password / Confirm"
quartet; the `.cols-3` modifier swaps to a 3-column layout for the
SSO header strip (display name / logo URL / metadata source). All
`.form-row > label.field` children share the same baseline because
their inner inputs share metrics via the global rule above. */
.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; align-items: end; }
.form-row.cols-3 { grid-template-columns: repeat(3, 1fr); }
.form-row.cols-2 { grid-template-columns: repeat(2, 1fr); }
.form-row label.field { margin-bottom: 0; }
@media (max-width: 900px) {
.form-row,
.form-row.cols-3,
.form-row.cols-2 { grid-template-columns: 1fr; }
}
/* Inline form rows. */
.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 ────────────────────────────────────── */
.queue-track {
/* ── Gate queue "horse race" visual ──────────────────────────────── */
.gate-track {
display: grid; gap: 6px;
padding: 10px 0;
}
.queue-row {
.gate-row {
display: grid; grid-template-columns: 32px 1fr auto auto; align-items: center;
gap: 14px;
padding: 8px 14px;
background: var(--bg-panel-2); border-radius: var(--radius);
border-left: 3px solid var(--accent);
}
.queue-row.assigned { border-left-color: var(--ok); }
.queue-row .pos { font-family: var(--mono); font-size: 15px; color: var(--accent); font-weight: 600; }
.queue-row.assigned .pos { color: var(--ok); }
.queue-row .mac { font-family: var(--mono); font-size: 13px; }
.queue-row .meta { color: var(--fg-dim); font-size: 12px; }
.gate-row.assigned { border-left-color: var(--ok); }
.gate-row .pos { font-family: var(--mono); font-size: 15px; color: var(--accent); font-weight: 600; }
.gate-row.assigned .pos { color: var(--ok); }
.gate-row .mac { font-family: var(--mono); font-size: 13px; }
.gate-row .meta { color: var(--fg-dim); font-size: 12px; }
.empty { color: var(--fg-dim); padding: 30px; text-align: center; }
.msg { color: var(--fg-dim); font-size: 12.5px; margin-top: 8px; }
@@ -481,238 +414,52 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
min-height: 480px;
box-shadow: var(--shadow-card);
}
/* Terminal pane colours follow the active theme. Hard-coded hexes
(#050505, #181818, #cfd6e2 etc.) were leaving the light-mode pane
looking dark; we keep palette-aware vars instead so the toggle works. */
.terminal .pane {
flex: 1; overflow: auto;
padding: 10px 14px;
font-family: var(--mono); font-size: 12.5px; line-height: 1.5;
color: var(--fg);
color: #cfd6e2;
white-space: pre-wrap; word-break: break-word;
}
.terminal .pane .lvl-error { color: var(--err); }
.terminal .pane .lvl-warn { color: var(--warn); }
.terminal .pane .lvl-info { color: var(--fg); }
.terminal .pane .lvl-debug { color: var(--fg-dim); }
.terminal .pane .lvl-trace { color: var(--fg-dimmer); }
.terminal .pane .ts { color: var(--fg-dimmer); }
.terminal .pane .tg { color: var(--accent); }
.terminal .pane .lvl-info { color: #cfd6e2; }
.terminal .pane .lvl-debug { color: #8b94a8; }
.terminal .pane .lvl-trace { color: #5a6379; }
.terminal .pane .ts { color: #5a6379; }
.terminal .pane .tg { color: #7cd3ff; }
.terminal .pane .echo { color: var(--accent); }
.terminal .input-row {
display: flex; align-items: center; gap: 8px;
padding: 8px 14px;
background: var(--bg-panel-2);
border-top: 1px solid var(--border);
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: var(--bg-panel-2);
border-bottom: 1px solid var(--border);
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 var(--border);
background: transparent; color: #8a94a7; border: 1px solid #1d2330;
font-weight: 500;
}
.terminal .toolbar button:hover { color: var(--fg); background: var(--bg-elev); }
/* Auth screen (first-run setup + login)
Used when /api/me reports setup_required or !authenticated. The
regular .shell is hidden; this overlay takes the full viewport so
the operator never sees half-loaded dashboard chrome while the auth
state is unknown. Same palette as the rest of the UI borrows the
Sonarr/Radarr layout (centered narrow card on the page background).
*/
.auth-screen {
position: fixed; inset: 0;
display: flex; align-items: center; justify-content: center;
background: var(--bg);
padding: 24px;
z-index: 100;
}
.auth-card {
width: 100%; max-width: 380px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 28px 28px 22px;
}
.auth-card .brand-row {
display: flex; align-items: center; gap: 12px;
margin-bottom: 18px;
}
.auth-card .brand-row img { width: 32px; height: 32px; flex: none; }
.auth-card .brand-row .name { font-size: 17px; font-weight: 600; letter-spacing: 0.2px; color: var(--fg); }
.auth-card h2 {
margin: 0 0 6px; font-size: 16px; font-weight: 600; color: var(--fg);
}
.auth-card .lede {
color: var(--fg-dim); font-size: 13px; margin: 0 0 18px;
line-height: 1.5;
}
.auth-card .field { margin-bottom: 12px; }
.auth-card input[type="text"],
.auth-card input[type="password"] {
width: 100%; background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 9px 11px; font: inherit; font-size: 13.5px;
}
.auth-card input:focus { outline: none; border-color: var(--accent); }
.auth-card .submit { width: 100%; padding: 9px 12px; margin-top: 6px; }
.auth-card .auth-err {
margin-top: 12px; color: var(--err); font-size: 12.5px;
}
.auth-card .auth-foot {
margin-top: 14px; padding-top: 12px;
border-top: 1px solid var(--border-soft);
color: var(--fg-dimmer); font-size: 11.5px; text-align: center;
}
.auth-card .sso-btn {
width: 100%; margin-top: 10px;
background: transparent; color: var(--fg);
border: 1px solid var(--border);
padding: 9px 12px;
}
.auth-card .sso-btn:hover {
background: var(--bg-panel-2); border-color: var(--accent); color: var(--fg);
}
.auth-card .sso-btn .meta { color: var(--fg-dim); font-size: 11px; margin-top: 2px; }
/* Top-right user menu (v0.4.6)
The "signed in as X" identity + sign-out moved out of the sidebar
footer in v0.4.6 the sidebar footer is now reserved for the
service-state trio (Service status / Advertised URL / Backend
version). The button matches the theme toggle's size + chrome so
the top-right reads as a tidy two-icon strip. */
.user-menu { position: relative; }
.user-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 36px; height: 32px;
background: transparent; color: var(--fg);
border: 1px solid var(--border); border-radius: 8px;
cursor: pointer; padding: 0;
transition: background 0.15s ease, border-color 0.15s ease;
}
.user-btn:hover { background: var(--bg-panel-2); border-color: var(--accent); }
.user-pop {
position: absolute; right: 0; top: 38px;
min-width: 200px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 6px;
z-index: 60;
display: flex; flex-direction: column; gap: 2px;
}
.user-pop[hidden] { display: none; }
.user-pop .user-pop-name {
padding: 8px 10px 6px;
border-bottom: 1px solid var(--border-soft);
margin-bottom: 4px;
color: var(--fg); font-weight: 600; font-size: 13px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.user-pop .user-pop-item {
text-align: left; width: 100%;
background: transparent; color: var(--fg);
border: 0; border-radius: var(--radius);
padding: 7px 10px; font: inherit; font-size: 13px; font-weight: 500;
cursor: pointer;
}
.user-pop .user-pop-item:hover {
background: var(--bg-panel-2); color: var(--fg);
}
.user-pop .user-pop-danger { color: var(--err); }
.user-pop .user-pop-danger:hover {
background: color-mix(in srgb, var(--err) 12%, transparent);
color: var(--err);
}
.terminal .toolbar button:hover { color: #e4e8ef; background: #1d2330; }
/* ── About card ─────────────────────────────────────────────────── */
.about-hero { padding: 20px 24px; }
.about-hero h2 { font-size: 22px; margin: 0 0 8px; color: var(--fg); }
/* Span the full main column rather than capping at 60ch the page is
read at typical desktop widths and the cap was leaving the right two
thirds of the panel awkwardly empty. */
.about-hero .lead { color: var(--fg-dim); font-size: 14px; max-width: none; }
.about-hero .lead { color: var(--fg-dim); font-size: 14px; max-width: 60ch; }
.about-hero .who { margin-top: 18px; font-size: 13px; }
.about-hero .who span { color: var(--fg-dim); }
.about-hero .who strong { color: var(--accent); }
.about-hero a { color: var(--accent); }
/* ── API reference (Settings → bottom) ─────────────────────────── */
.api-ref { display: grid; gap: 18px; padding: 16px; }
.api-ref .group h3 {
margin: 0 0 8px; font-size: 13px; color: var(--fg-dim);
text-transform: uppercase; letter-spacing: 0.8px;
}
.api-ref .ep {
display: grid; grid-template-columns: 64px minmax(200px, 1fr) 2fr;
gap: 12px; align-items: baseline;
padding: 6px 0; border-top: 1px solid var(--border-soft);
font-size: 13px;
}
.api-ref .ep:first-child { border-top: 0; }
.api-ref .ep .method {
font-family: var(--mono); font-weight: 600; font-size: 11px;
padding: 2px 6px; border-radius: 4px;
text-align: center; letter-spacing: 0.6px;
}
.api-ref .ep .method.get { background: color-mix(in srgb, var(--ok) 22%, transparent); color: var(--ok); }
.api-ref .ep .method.post { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--accent); }
.api-ref .ep .method.put { background: color-mix(in srgb, var(--warn) 22%, transparent); color: var(--warn); }
.api-ref .ep .method.delete { background: color-mix(in srgb, var(--err) 22%, transparent); color: var(--err); }
.api-ref .ep .path { font-family: var(--mono); color: var(--fg); word-break: break-all; }
.api-ref .ep .desc { color: var(--fg-dim); }
@media (max-width: 900px) {
.api-ref .ep { grid-template-columns: 1fr; gap: 4px; }
.api-ref .ep .method { justify-self: start; }
}
/* ── Disk space card ───────────────────────────────────────────── */
.diskbar {
height: 10px; border-radius: 5px;
background: var(--bg-elev);
overflow: hidden; margin-top: 8px;
}
.diskbar .fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-dim), var(--accent));
transition: width 0.4s ease;
}
.diskbar.warn .fill { background: var(--warn); }
.diskbar.full .fill { background: var(--err); }
.disk-meta { display: flex; gap: 14px; font-size: 12px; color: var(--fg-dim); margin-top: 8px; flex-wrap: wrap; }
.disk-meta strong { color: var(--fg); font-weight: 600; font-variant-numeric: tabular-nums; }
/* ── Logo upload (Settings) ────────────────────────────────────── */
.logo-preview {
display: flex; align-items: center; gap: 14px;
padding: 12px;
background: var(--bg-panel-2);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.logo-preview .swatch {
width: 56px; height: 56px;
display: flex; align-items: center; justify-content: center;
background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius);
flex: none;
}
.logo-preview .swatch img { max-width: 48px; max-height: 48px; }
.logo-preview .info { flex: 1; min-width: 0; }
.logo-preview .info .name { color: var(--fg); font-weight: 600; }
.logo-preview .info .meta { color: var(--fg-dim); font-size: 12px; margin-top: 2px; }
+101 -991
View File
File diff suppressed because it is too large Load Diff
+16 -50
View File
@@ -4,23 +4,16 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<title>OpenPXE</title>
<!-- v0.4.61: the `?v=…` query string is replaced by the server at
request time with the running OpenPXE version. That guarantees a
fresh URL on every upgrade so browsers (and intermediary proxies)
can't keep serving stale JS / CSS / branding from before the
deploy. Combined with `Cache-Control: no-cache, must-revalidate`
on the asset handlers, the practical caching window is one
version. -->
<link rel="stylesheet" href="/assets/app.css?v={{ASSET_VERSION}}" />
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg?v={{ASSET_VERSION}}" />
<title>PXEForge</title>
<link rel="stylesheet" href="/assets/app.css" />
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg" />
<!-- Theme is read from localStorage *before* paint to avoid the
dark→light flash on every navigation. Falls back to the OS
preference and finally to dark. -->
<script>
(function() {
try {
var stored = localStorage.getItem('openpxe-theme');
var stored = localStorage.getItem('pxeforge-theme');
var theme = stored || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {
@@ -33,15 +26,18 @@
<div class="shell">
<aside class="sidebar">
<div class="brand">
<img src="/assets/logo.svg?v={{ASSET_VERSION}}" alt="OpenPXE" />
<strong>OpenPXE</strong>
<img src="/assets/logo.svg" alt="" />
<div>
<strong>PXEForge</strong>
<div class="sub">v<span data-bind="version">0.2.0</span></div>
</div>
</div>
<nav>
<a data-view="dashboard" class="active">Dashboard</a>
<a data-view="network">Network</a>
<a data-view="queue">
Queue
<span class="count" data-bind="queue_count">0</span>
<a data-view="gate">
Forge Gate
<span class="count" data-bind="gate_count">0</span>
</a>
<a data-view="storage">
Storage
@@ -52,30 +48,21 @@
<span class="count" data-bind="host_count">0</span>
</a>
<a data-view="terminal">Terminal</a>
<a data-view="settings">Settings</a>
<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>
<!-- The brand badge at the top can be overridden by operator-uploaded
logos; keep "OpenPXE v…" pinned in the footer so the backend
identity is always visible regardless of branding. -->
<div class="footer-version">OpenPXE&nbsp;v<span data-bind="version">0.4.61</span></div>
</div>
</aside>
<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>
<span class="chip"><strong data-bind="gate_count2">0</strong>&nbsp;at gate</span>
<button id="theme-toggle" class="theme-toggle" type="button"
aria-label="Toggle light/dark theme" title="Toggle theme (T)">
<!-- Two glyphs; CSS shows whichever matches the active theme. -->
@@ -96,32 +83,11 @@
<path d="M20.5 14A8 8 0 0 1 10 3.5 a8 8 0 1 0 10.5 10.5z"/>
</svg>
</button>
<!-- v0.4.6: signed-in operator menu. Sits next to the theme toggle
in the top-right corner so the sidebar footer stays clean for
the "Service status / Advertised URL / Backend version" trio.
The whole block is hidden until /api/me confirms a session. -->
<div class="user-menu" data-bind="user_menu_wrap" style="display:none">
<button id="user-menu-btn" class="user-btn" type="button"
aria-label="Account menu" aria-haspopup="true" aria-expanded="false"
title="Account">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="8" r="3.6"/>
<path d="M4.5 20a7.5 7.5 0 0 1 15 0"/>
</svg>
</button>
<div id="user-menu-pop" class="user-pop" data-bind="user_menu_pop" hidden>
<div class="user-pop-name" data-bind="user_pop_name"></div>
<button type="button" class="user-pop-item" data-bind="user_pop_edit">Edit account</button>
<button type="button" class="user-pop-item user-pop-danger" data-bind="user_pop_logout">Sign out</button>
</div>
</div>
</header>
<main class="main" id="view-root"></main>
</div>
<script src="/assets/app.js?v={{ASSET_VERSION}}"></script>
<script src="/assets/app.js"></script>
</body>
</html>
+17 -35
View File
@@ -1,54 +1,36 @@
//! Offline-only web UI. Everything the browser needs (HTML, CSS, JS, SVG
//! logo) is embedded in the compiled binary via `include_str!` /
//! `include_bytes!`. No CDN, no external fonts, no remote images —
//! OpenPXE renders identically on an air-gapped network.
//! PXEForge renders identically on an air-gapped network.
//!
//! Layout follows the Netbox Labs pattern: dark left sidebar with primary
//! nav, top bar with secondary tabs, card-dense content panels.
#![forbid(unsafe_code)]
/// Render the top-level page.
///
/// * `base_url` is interpolated into the footer so operators can see at
/// a glance what URL clients are PXE-booting from.
/// * `asset_version` is appended as `?v=…` to every asset URL so each
/// release ships with brand-new asset URLs — browsers (and any
/// intermediary proxy) can't keep serving last release's `app.js`
/// when we know the new one is incompatible. Combined with
/// `Cache-Control: no-cache, must-revalidate` on the asset handlers,
/// the worst-case caching window is one version.
/// Render the top-level page. `base_url` is interpolated into the footer
/// so operators can see at a glance what URL clients are PXE-booting from.
#[must_use]
pub fn index_html(base_url: &str, asset_version: &str) -> String {
INDEX_HTML
.replace("{{BASE_URL}}", base_url)
.replace("{{ASSET_VERSION}}", asset_version)
pub fn index_html(base_url: &str) -> String {
INDEX_HTML.replace("{{BASE_URL}}", base_url)
}
#[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.
/// Animated forging anvil — sparks rise + glow pulse. Used for the
/// imaging-progress widget and any "I'm working" loading state. Pure
/// SVG + SMIL, no JS, no GIF.
#[must_use]
pub fn loader_svg() -> &'static str {
LOADER_SVG
}
pub fn anvil_forge_svg() -> &'static str { ANVIL_FORGE_SVG }
const INDEX_HTML: &str = include_str!("index.html");
const APP_CSS: &str = include_str!("app.css");
const APP_JS: &str = include_str!("app.js");
const LOGO_SVG: &str = include_str!("logo.svg");
const LOADER_SVG: &str = include_str!("loader.svg");
const INDEX_HTML: &str = include_str!("index.html");
const APP_CSS: &str = include_str!("app.css");
const APP_JS: &str = include_str!("app.js");
const LOGO_SVG: &str = include_str!("logo.svg");
const ANVIL_FORGE_SVG: &str = include_str!("anvil-forge.svg");
-36
View File
@@ -1,36 +0,0 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<title>OpenPXE — loading</title>
<!-- Larger, bolder version of the brand mark for "I'm working" states:
page transitions, the imaging-progress widget on Dashboard / Queue.
The gradient slide is faster (3s) and we add a subtle scale pulse
so the disc looks alive even when paired with a static progress bar.
White inner glow keeps the colours legible against the panel bg. -->
<defs>
<linearGradient id="opxRainbowLg" 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"/>
<animate attributeName="x1" values="0;-1;0" dur="3s" repeatCount="indefinite"/>
<animate attributeName="x2" values="1;0;1" dur="3s" repeatCount="indefinite"/>
</linearGradient>
<radialGradient id="opxGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="rgba(255,255,255,0.6)"/>
<stop offset="60%" stop-color="rgba(255,255,255,0.05)"/>
<stop offset="100%" stop-color="rgba(255,255,255,0)"/>
</radialGradient>
</defs>
<g>
<circle cx="32" cy="32" r="26" fill="url(#opxRainbowLg)"
stroke="rgba(0,0,0,0.2)" stroke-width="1.2">
<animate attributeName="r" values="25;27;25" dur="2.4s" repeatCount="indefinite"/>
</circle>
<!-- Inner highlight to give the disc a hint of dimensionality. -->
<circle cx="28" cy="26" r="14" fill="url(#opxGlow)"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

+28 -21
View File
@@ -1,25 +1,32 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>OpenPXE</title>
<!-- Brand mark to match openpxe.com: a circular medallion filled with
the "rainbow-horizon" gradient, sliding 200% across to give a slow
hue rotation. Subtle stroke + drop shadow for legibility on either
theme. SMIL keeps it self-driving with no JS or CSS dependency. -->
<svg viewBox="0 0 200 130" xmlns="http://www.w3.org/2000/svg" fill="none">
<title>PXEForge</title>
<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"/>
<animate attributeName="x1" values="0;-1;0" dur="12s" repeatCount="indefinite"/>
<animate attributeName="x2" values="1;0;1" dur="12s" repeatCount="indefinite"/>
<linearGradient id="anvilBody" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#aab3c2"/>
<stop offset="55%" stop-color="#7d8696"/>
<stop offset="100%" stop-color="#525a6b"/>
</linearGradient>
<linearGradient id="anvilFace" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#cdd5e1"/>
<stop offset="100%" stop-color="#9aa3b3"/>
</linearGradient>
<linearGradient id="anvilBase" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#3b4252"/>
<stop offset="100%" stop-color="#252a36"/>
</linearGradient>
</defs>
<!-- Outer hairline ring softens the edge in light mode; subtle in dark. -->
<circle cx="12" cy="12" r="10.5" fill="url(#opxRainbow)"
stroke="rgba(0,0,0,0.18)" stroke-width="0.6"/>
<!-- Horn (left point) + face (top) -->
<path d="M14 36 L72 30 L162 30 L162 46 L72 46 Z"
fill="url(#anvilFace)" stroke="#1d2330" stroke-width="2.4" stroke-linejoin="round"/>
<!-- Body / waist -->
<path d="M70 46 L160 46 L142 70 L88 70 Z"
fill="url(#anvilBody)" stroke="#1d2330" stroke-width="2.4" stroke-linejoin="round"/>
<!-- Base plinth -->
<path d="M62 96 L168 96 L160 110 L70 110 Z"
fill="url(#anvilBase)" stroke="#0d1018" stroke-width="2.4" stroke-linejoin="round"/>
<!-- Pillar between body and base -->
<rect x="92" y="70" width="46" height="26" fill="url(#anvilBody)"
stroke="#1d2330" stroke-width="2.4"/>
<!-- Highlight along top face -->
<line x1="74" y1="34" x2="158" y2="34" stroke="#e6ecf5" stroke-width="1.2" opacity="0.7"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+38 -73
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7
#
# OpenPXE — multi-stage build.
# PXEForge — multi-stage build.
#
# Design:
# - stage `fetch`: runs scripts/fetch-ipxe.sh to pull official iPXE binaries
@@ -8,23 +8,15 @@
# - stage `build`: compiles the workspace with cargo in release mode.
# - stage `runtime`: Debian slim image with setcap for NET_BIND_SERVICE,
# running as a non-root UID. No shell in PATH for the service user;
# attacker surface is just the openpxe binary + libc.
# attacker surface is just the pxeforge binary + libc.
#
# Why not distroless? We want setcap support and easy debug (`oc rsh`).
# Debian slim at ~75 MB + binary ~25 MB is fine for a PXE server that
# spends most of its life idle.
ARG RUST_VERSION=1.95
ARG RUST_VERSION=1.82
########## fetch iPXE binaries + wimboot ##########
# v0.4.62: kept on the boot.ipxe.org pre-builds for the moment. We
# want PNG support (so `console --picture` paints the operator's logo
# on the PXE menu) but the obvious path — adding a new `ipxe-build`
# stage that compiles iPXE from source with `IMAGE_PNG` enabled —
# runs into a QEMU/gcc instability when cross-emulating x86_64 on
# arm64 build hosts (intermittent `cc1` segfaults). The compositor
# at /branding/pxe-logo is already wired so when the iPXE rebuild
# lands (on native x86_64 hardware), no other code change is needed.
########## fetch iPXE binaries ##########
FROM debian:12-slim AS fetch
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
@@ -32,49 +24,28 @@ WORKDIR /src
COPY scripts/fetch-ipxe.sh scripts/fetch-ipxe.sh
RUN mkdir -p assets/ipxe && bash scripts/fetch-ipxe.sh
########## build openpxe ##########
########## build pxeforge ##########
FROM rust:${RUST_VERSION}-bookworm AS build
WORKDIR /src
# v0.4.5: build a fully static musl binary (matches Bootimus v0.1.70's
# move). The resulting `/openpxe` has no glibc dependency at all, which:
# - Lets the runtime stage be any Linux distro (we still ship Debian
# slim for the `samba` / `wimtools` / `nfs-common` shellouts, but a
# scratch/distroless variant becomes a one-line swap).
# - Cuts a class of "GLIBC_2.39 not found" surprises when running on
# older RHEL/Rocky hosts that don't match Debian 12's libc version.
# - Sidesteps cross-compilation snags (the binary is its own world).
#
# x86_64-unknown-linux-musl is fully static by default (no extra
# RUSTFLAGS needed). musl-tools provides the linker.
RUN apt-get update \
&& apt-get install -y --no-install-recommends musl-tools \
&& rm -rf /var/lib/apt/lists/* \
&& rustup target add x86_64-unknown-linux-musl
# Copy the whole workspace in one go. We used to do a two-pass "cache-prime
# with stubs, then real build" dance for dep-compile reuse; that turned out
# to silently serve stale stub binaries when cargo's fingerprint didn't
# notice the source swap. A single build is ~1.5 min longer on cold cache
# but guarantees the binary reflects the sources we copied.
# Do not copy rust-toolchain.toml into the image. The local workspace pins
# developer tooling, but inside Docker we intentionally use the Rust version
# selected by the base image. Copying rust-toolchain.toml with
# `channel = "stable"` makes rustup download a second full toolchain during
# `cargo build`, which is slow and can exhaust small Colima/CI disks.
COPY Cargo.toml Cargo.lock ./
COPY Cargo.toml rust-toolchain.toml ./
COPY crates/ crates/
COPY --from=fetch /src/assets/ipxe /src/assets/ipxe
# Cache cargo registry + target across builds. The mtime touch is
# Cache cargo registry + target across builds. The `--no-edit` touch is
# belt-and-suspenders: cargo occasionally misses mtime-only changes on
# networked FS; this forces a fingerprint check.
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/src/target,sharing=locked \
find crates -name '*.rs' -exec touch {} + && \
cargo build --release --target x86_64-unknown-linux-musl --bin openpxe && \
cp target/x86_64-unknown-linux-musl/release/openpxe /openpxe && \
ls -l /openpxe
cargo build --release --bin pxeforge && \
cp target/release/pxeforge /pxeforge && \
ls -l /pxeforge
########## runtime ##########
FROM debian:12-slim AS runtime
@@ -83,49 +54,43 @@ RUN apt-get update \
ca-certificates libcap2-bin tini gosu iproute2 \
wimtools samba nfs-common \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10001 --home-dir /var/lib/openpxe --shell /usr/sbin/nologin openpxe \
&& mkdir -p /var/lib/openpxe/isos /var/lib/openpxe/work /var/lib/openpxe/smb \
&& chown -R openpxe:openpxe /var/lib/openpxe
# v0.4.5: the openpxe binary itself is now built against musl and is
# fully static — no glibc dependency. The runtime stage still ships
# Debian slim because OpenPXE shells out to the four packages below for
# functionality we deliberately don't reimplement in-process:
# wimtools - `wimlib-imagex`, used to inject startnet.cmd into boot.wim.
# samba - `smbd` serves extracted Windows install media on :445 so
# WinPE can `net use`. Guest read-only, scoped to
# /var/lib/openpxe/smb.
# nfs-common - `mount.nfs` / `mount.nfs4` for the Storage tab's NFS
# share manager. Mount requires CAP_SYS_ADMIN; without it
# mount(2) returns EPERM and the manager surfaces a clear
# error in the UI.
# iproute2 - `ip addr` / `ip route` for the auto-detected Network
# tab fields (NIC name, subnet mask, default gateway).
# Tiny, always available; we don't pull in netlink crates
# for this one-shot startup probe.
# gosu - drops privileges cleanly from root after the entrypoint
# fixes bind-mount ownership (common OpenShift/Docker UX
# issue).
# A future "openpxe-static" variant could drop everything except the
# binary onto distroless once we move the Windows + NFS legs to
# in-process Rust crates.
&& useradd --system --uid 10001 --home-dir /var/lib/pxeforge --shell /usr/sbin/nologin pxeforge \
&& mkdir -p /var/lib/pxeforge/isos /var/lib/pxeforge/work /var/lib/pxeforge/smb \
&& chown -R pxeforge:pxeforge /var/lib/pxeforge
# Runtime deps explained:
# wimtools - provides `wimlib-imagex`, used to inject startnet.cmd into boot.wim.
# samba - `smbd` serves extracted Windows install media on :445 for WinPE
# to `net use`. Guest read-only, scoped to /var/lib/pxeforge/smb.
# nfs-common - provides `mount.nfs` / `mount.nfs4` for the Storage tab's
# NFS share manager. Mount also requires the container to run
# with CAP_SYS_ADMIN — without it, mount(2) returns EPERM and
# the manager surfaces a clear error in the UI instead of
# failing silently.
# iproute2 - `ip addr` / `ip route` for the auto-detected Network tab
# fields (NIC name, subnet mask, default gateway). Tiny,
# always available; we don't pull in netlink crates for
# this one-shot startup probe.
# gosu - drops privileges cleanly from root after the entrypoint fixes
# bind-mount ownership (common OpenShift/Docker UX issue).
# Windows-specific tools only activate when the WebUI toggle is on.
COPY --from=build /openpxe /usr/local/bin/openpxe
COPY --from=build /pxeforge /usr/local/bin/pxeforge
COPY deploy/docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Grant the binary the ability to bind <1024 ports as a non-root user.
# This is the only capability OpenPXE needs for proxy-mode DHCP + TFTP + HTTP.
RUN setcap cap_net_bind_service=+ep /usr/local/bin/openpxe
# This is the only capability PXEForge needs for proxy-mode DHCP + TFTP + HTTP.
RUN setcap cap_net_bind_service=+ep /usr/local/bin/pxeforge
# IMPORTANT: we do NOT `USER openpxe` here. The entrypoint runs as root,
# chowns the mounted data dirs, then execs the binary via gosu as openpxe.
# IMPORTANT: we do NOT `USER pxeforge` here. The entrypoint runs as root,
# chowns the mounted data dirs, then execs the binary via gosu as pxeforge.
# OpenShift ignores USER directives anyway (it injects its own uid), and
# there entrypoint.sh's non-root branch just execs directly.
WORKDIR /var/lib/openpxe
WORKDIR /var/lib/pxeforge
ENV OPENPXE_ISO_DIR=/var/lib/openpxe/isos \
OPENPXE_WORK_DIR=/var/lib/openpxe/work \
OPENPXE_LOG=info,openpxe=info
ENV PXEFORGE_ISO_DIR=/var/lib/pxeforge/isos \
PXEFORGE_WORK_DIR=/var/lib/pxeforge/work \
PXEFORGE_LOG=info,pxeforge=info
EXPOSE 67/udp 69/udp 4011/udp 80/tcp 445/tcp
+9 -9
View File
@@ -2,9 +2,9 @@
# Container entrypoint that handles the common bind-mount-as-root case.
#
# When volumes are bind-mounted into the container (e.g. `-v ./data/isos:...`),
# they come up owned by the host uid:gid — often root:root. The openpxe
# they come up owned by the host uid:gid — often root:root. The pxeforge
# binary runs as uid 10001 and can't write there. This script, when started
# as root, chowns the two state dirs to the openpxe user, then drops
# as root, chowns the two state dirs to the pxeforge user, then drops
# privileges via gosu before execing the binary.
#
# If the container is already running as non-root (OpenShift does this via
@@ -13,20 +13,20 @@
# or the operator is on their own for permissions.
set -e
OPENPXE_UID=${OPENPXE_UID:-10001}
OPENPXE_GID=${OPENPXE_GID:-10001}
DATA_DIRS="/var/lib/openpxe/isos /var/lib/openpxe/work /var/lib/openpxe/smb"
PXEFORGE_UID=${PXEFORGE_UID:-10001}
PXEFORGE_GID=${PXEFORGE_GID:-10001}
DATA_DIRS="/var/lib/pxeforge/isos /var/lib/pxeforge/work /var/lib/pxeforge/smb"
if [ "$(id -u)" = "0" ]; then
for d in $DATA_DIRS; do
if [ -d "$d" ]; then
chown -R "${OPENPXE_UID}:${OPENPXE_GID}" "$d" 2>/dev/null || true
chown -R "${PXEFORGE_UID}:${PXEFORGE_GID}" "$d" 2>/dev/null || true
fi
done
# Re-exec ourselves under the openpxe user so the binary inherits a
# Re-exec ourselves under the pxeforge user so the binary inherits a
# clean process environment and a predictable umask.
exec gosu "${OPENPXE_UID}:${OPENPXE_GID}" /usr/local/bin/openpxe "$@"
exec gosu "${PXEFORGE_UID}:${PXEFORGE_GID}" /usr/local/bin/pxeforge "$@"
fi
# Non-root: straight exec, no chown attempt.
exec /usr/local/bin/openpxe "$@"
exec /usr/local/bin/pxeforge "$@"
+1 -1
View File
@@ -1,7 +1,7 @@
apiVersion: v1
kind: Namespace
metadata:
name: openpxe
name: pxeforge
labels:
# Allow privileged pods (host-network) in this namespace only. The pod
# itself still runs non-root with only NET_BIND_SERVICE — privileged
+11 -11
View File
@@ -1,5 +1,5 @@
---
# Custom SCC for OpenPXE.
# Custom SCC for PXEForge.
#
# The default `restricted-v2` SCC blocks host network and all capabilities,
# which PXE cannot tolerate: DHCPDISCOVER is an L2 broadcast that CNI overlays
@@ -19,10 +19,10 @@
apiVersion: security.openshift.io/v1
kind: SecurityContextConstraints
metadata:
name: openpxe-scc
name: pxeforge-scc
annotations:
kubernetes.io/description: >-
Minimal SCC for OpenPXE: host network + NET_BIND_SERVICE only, no raw
Minimal SCC for PXEForge: host network + NET_BIND_SERVICE only, no raw
sockets, no privileged mode.
allowPrivilegedContainer: false
allowPrivilegeEscalation: false
@@ -54,27 +54,27 @@ volumes:
users: []
groups: []
---
# Bind the SCC to the openpxe service account.
# Bind the SCC to the pxeforge service account.
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: openpxe-scc-use
name: pxeforge-scc-use
rules:
- apiGroups: ["security.openshift.io"]
resources: ["securitycontextconstraints"]
resourceNames: ["openpxe-scc"]
resourceNames: ["pxeforge-scc"]
verbs: ["use"]
---
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: openpxe-scc-use
namespace: openpxe
name: pxeforge-scc-use
namespace: pxeforge
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: openpxe-scc-use
name: pxeforge-scc-use
subjects:
- kind: ServiceAccount
name: openpxe
namespace: openpxe
name: pxeforge
namespace: pxeforge
+9 -9
View File
@@ -2,29 +2,29 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: openpxe
namespace: openpxe
name: pxeforge
namespace: pxeforge
---
apiVersion: v1
kind: ConfigMap
metadata:
name: openpxe-config
namespace: openpxe
name: pxeforge-config
namespace: pxeforge
data:
# Toggle DHCP proxy on or off. "proxy" = answer PXE clients alongside an
# existing DHCP server. "disabled" = require operator to point an external
# DHCP at us via next-server/filename.
OPENPXE_DHCP_MODE: "proxy"
PXEFORGE_DHCP_MODE: "proxy"
# Override if auto-detection picks the wrong NIC in multi-homed pods.
# Leave unset to auto-detect from the node's primary IPv4.
# OPENPXE_PUBLIC_IP: "10.0.0.5"
OPENPXE_LOG: "info,openpxe=info"
# PXEFORGE_PUBLIC_IP: "10.0.0.5"
PXEFORGE_LOG: "info,pxeforge=info"
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: openpxe-isos
namespace: openpxe
name: pxeforge-isos
namespace: pxeforge
spec:
# ReadWriteOnce is fine — we deploy as a single replica since DHCP proxy
# coordination across replicas is not useful (clients hit whichever node
+12 -12
View File
@@ -2,10 +2,10 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: openpxe
namespace: openpxe
name: pxeforge
namespace: pxeforge
labels:
app.kubernetes.io/name: openpxe
app.kubernetes.io/name: pxeforge
spec:
# Single replica by design (see PVC comment). If HA is needed later, split
# the HTTP/web plane (scalable, stateless) from the DHCP-proxy/TFTP plane
@@ -15,13 +15,13 @@ spec:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: openpxe
app.kubernetes.io/name: pxeforge
template:
metadata:
labels:
app.kubernetes.io/name: openpxe
app.kubernetes.io/name: pxeforge
spec:
serviceAccountName: openpxe
serviceAccountName: pxeforge
# L2 broadcast (DHCPDISCOVER) does not cross most CNI overlays into
# pod netns. Host network is the working path.
hostNetwork: true
@@ -33,8 +33,8 @@ spec:
runAsUser: 10001
fsGroup: 10001
containers:
- name: openpxe
image: gitea.milesward.dev/mward4/openpxe:0.4.1
- name: pxeforge
image: ghcr.io/casperadmin/pxeforge:0.1.0
imagePullPolicy: IfNotPresent
ports:
- name: dhcp
@@ -59,7 +59,7 @@ spec:
protocol: TCP
envFrom:
- configMapRef:
name: openpxe-config
name: pxeforge-config
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
@@ -70,9 +70,9 @@ spec:
add: ["NET_BIND_SERVICE"]
volumeMounts:
- name: isos
mountPath: /var/lib/openpxe/isos
mountPath: /var/lib/pxeforge/isos
- name: work
mountPath: /var/lib/openpxe/work
mountPath: /var/lib/pxeforge/work
- name: tmp
mountPath: /tmp
readinessProbe:
@@ -97,7 +97,7 @@ spec:
volumes:
- name: isos
persistentVolumeClaim:
claimName: openpxe-isos
claimName: pxeforge-isos
- name: work
emptyDir: {}
- name: tmp
+7 -7
View File
@@ -5,14 +5,14 @@
apiVersion: v1
kind: Service
metadata:
name: openpxe
namespace: openpxe
name: pxeforge
namespace: pxeforge
labels:
app.kubernetes.io/name: openpxe
app.kubernetes.io/name: pxeforge
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: openpxe
app.kubernetes.io/name: pxeforge
ports:
- name: http
port: 80
@@ -29,12 +29,12 @@ spec:
apiVersion: route.openshift.io/v1
kind: Route
metadata:
name: openpxe
namespace: openpxe
name: pxeforge
namespace: pxeforge
spec:
to:
kind: Service
name: openpxe
name: pxeforge
weight: 100
port:
targetPort: http
+24 -24
View File
@@ -1,12 +1,12 @@
# OpenPXE on Unraid
# PXEForge on Unraid
Three paths from "I have an Unraid box with Gitea on it" to "PXE clients
boot from OpenPXE". Pick the one that matches what you have.
boot from PXEForge". 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.4.1` (or your equivalent) and
`gitea.milesward.dev/mward4/pxeforge:0.1.0` (or your equivalent) and
every Unraid template / docker-compose just references the tag.
Pre-flight:
@@ -24,12 +24,12 @@ Run on the Unraid host (Settings → Terminal, or `ssh root@unraid`):
GITEA_TOKEN=<your-token>
curl -fsSL \
-H "Authorization: token $GITEA_TOKEN" \
http://localhost:3000/mward4/OpenPXE/raw/branch/main/scripts/build-and-publish-unraid.sh \
-o /tmp/openpxe-publish.sh
http://localhost:3000/mward4/PXEForge/raw/branch/main/scripts/build-and-publish-unraid.sh \
-o /tmp/pxeforge-publish.sh
# Run it. ~6 min on Unraid hardware (native amd64, no QEMU).
chmod +x /tmp/openpxe-publish.sh
GITEA_TOKEN=$GITEA_TOKEN /tmp/openpxe-publish.sh
chmod +x /tmp/pxeforge-publish.sh
GITEA_TOKEN=$GITEA_TOKEN /tmp/pxeforge-publish.sh
```
What it does:
@@ -40,24 +40,24 @@ 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.4.1` 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.4.1` |
| Repository | `gitea.milesward.dev/mward4/pxeforge:0.1.0` |
| Network | `host` |
| Extra args | `--cap-add=NET_BIND_SERVICE` |
Volume mounts (paths inside container in **bold**):
- **`/var/lib/openpxe/isos`** ↔ `/mnt/user/appdata/openpxe/isos`
- **`/var/lib/openpxe/work`** ↔ `/mnt/user/appdata/openpxe/work`
- **`/var/lib/openpxe/smb`** ↔ `/mnt/user/appdata/openpxe/smb`
- **`/var/lib/pxeforge/isos`** ↔ `/mnt/user/appdata/pxeforge/isos`
- **`/var/lib/pxeforge/work`** ↔ `/mnt/user/appdata/pxeforge/work`
- **`/var/lib/pxeforge/smb`** ↔ `/mnt/user/appdata/pxeforge/smb`
Or skip the manual UI by dropping `openpxe.xml` (in this directory)
Or skip the manual UI by dropping `pxeforge.xml` (in this directory)
into `/boot/config/plugins/dockerMan/templates-user/` and Unraid will
list it as a one-click template.
@@ -70,15 +70,15 @@ Skip the registry entirely. Useful for "hack on it locally" iterations.
```bash
ssh root@unraid
cd /mnt/user/appdata
git clone http://localhost:3000/mward4/OpenPXE.git openpxe-src
cd openpxe-src
git clone http://localhost:3000/mward4/PXEForge.git pxeforge-src
cd pxeforge-src
bash scripts/fetch-ipxe.sh
docker compose -f docker-compose.yml up -d --build openpxe
docker compose -f docker-compose.yml up -d --build pxeforge
```
The bundled `docker-compose.yml` already wires host networking, the
right cap_add, and bind-mounts to `./data/`. Edit those bind-mount
paths if you want them under `/mnt/user/appdata/openpxe/`.
paths if you want them under `/mnt/user/appdata/pxeforge/`.
## Path C — `docker load` from a tarball I built off-box
@@ -88,14 +88,14 @@ then:
```bash
# On the build host
docker save openpxe:0.4.1 | gzip > openpxe-0.4.1.tar.gz
docker save pxeforge:0.1.0 | gzip > pxeforge-0.1.0.tar.gz
# Transfer (rsync / scp / SMB / ZFS-replicate / sneakernet)
scp openpxe-0.4.1.tar.gz root@unraid:/tmp/
scp pxeforge-0.1.0.tar.gz root@unraid:/tmp/
# On Unraid
gunzip -c /tmp/openpxe-0.4.1.tar.gz | docker load
docker tag openpxe:0.4.1 gitea.milesward.dev/mward4/openpxe:0.4.1
gunzip -c /tmp/pxeforge-0.1.0.tar.gz | docker load
docker tag pxeforge:0.1.0 gitea.milesward.dev/mward4/pxeforge:0.1.0
```
If you want it pullable by tag from other Unraid templates, push to
@@ -119,16 +119,16 @@ show up in the Dashboard's "Recent connections" table within seconds.
## Common gotchas
- **DHCP collision.** Don't run two PXE _proxies_ on the same broadcast
domain. OpenPXE runs in proxy mode and never offers IP leases, so
domain. PXEForge runs in proxy mode and never offers IP leases, so
it coexists with whatever DHCP server is already on the network —
but two proxies racing each other will whichever-wins at random.
- **Host networking only.** Bridge mode containers don't see broadcast
DHCP. There's no working bridge-mode config for a PXE server.
- **Permissions on `/mnt/user/appdata/openpxe`.** The container runs
- **Permissions on `/mnt/user/appdata/pxeforge`.** The container runs
as uid 10001 by default. The entrypoint chowns the bind mounts to
10001 on first start, but only if the container itself has root —
`--user=root` isn't needed; the multi-stage Dockerfile starts as
root, fixes perms, then drops to openpxe via gosu.
root, fixes perms, then drops to pxeforge via gosu.
- **NFS mounts in the Storage tab.** Mounting NFS inside the container
needs `CAP_SYS_ADMIN`. To enable, add `--cap-add=SYS_ADMIN` to the
Unraid template's "Extra args" — but understand that's a meaningful
@@ -1,12 +1,12 @@
<?xml version="1.0"?>
<!--
Unraid Docker template for OpenPXE.
Unraid Docker template for PXEForge.
Drop this file into /boot/config/plugins/dockerMan/templates-user/
on your Unraid box (or import via the Docker tab → "Add Container" →
"Template Repositories" if you publish it on a Gitea raw URL).
IMPORTANT: OpenPXE needs host networking for DHCP/TFTP raw broadcasts.
IMPORTANT: PXEForge needs host networking for DHCP/TFTP raw broadcasts.
Bridge mode will NOT work — clients can't see broadcast DHCP from a
bridged container. The template forces NetworkType=host below.
@@ -21,21 +21,21 @@
Web UI: http://<unraid-ip>/ (port 80)
-->
<Container version="2">
<Name>OpenPXE</Name>
<Repository>gitea.milesward.dev/mward4/openpxe:latest</Repository>
<Registry>https://gitea.milesward.dev/mward4/-/packages/container/openpxe</Registry>
<Name>PXEForge</Name>
<Repository>gitea.milesward.dev/mward4/pxeforge:latest</Repository>
<Registry>https://gitea.milesward.dev/mward4/-/packages/container/pxeforge</Registry>
<Network>host</Network>
<MyIP/>
<Shell>sh</Shell>
<Privileged>false</Privileged>
<Support>https://gitea.milesward.dev/mward4/OpenPXE/issues</Support>
<Project>https://gitea.milesward.dev/mward4/OpenPXE</Project>
<Support>https://gitea.milesward.dev/mward4/PXEForge/issues</Support>
<Project>https://gitea.milesward.dev/mward4/PXEForge</Project>
<Overview>
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 Gated 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
@@ -44,7 +44,7 @@
<Category>Network:Other Network:Management</Category>
<WebUI>http://[IP]/</WebUI>
<TemplateURL/>
<Icon>https://gitea.milesward.dev/mward4/OpenPXE/raw/branch/main/crates/webui/src/logo.svg</Icon>
<Icon>https://gitea.milesward.dev/mward4/PXEForge/raw/branch/main/crates/webui/src/logo.svg</Icon>
<ExtraParams>--cap-add=NET_BIND_SERVICE</ExtraParams>
<PostArgs/>
<CPUset/>
@@ -53,23 +53,23 @@
<DonateLink/>
<Requires>
Host networking. Unraid&#39;s built-in DHCP server (if any) must
not collide with a network that already has DHCP — OpenPXE runs
not collide with a network that already has DHCP — PXEForge runs
in proxy mode and coexists, but only one DHCP _proxy_ should reply
per broadcast domain.
</Requires>
<Config Name="ISOs" Target="/var/lib/openpxe/isos" Default="/mnt/user/appdata/openpxe/isos"
<Config Name="ISOs" Target="/var/lib/pxeforge/isos" Default="/mnt/user/appdata/pxeforge/isos"
Mode="rw" Description="Where uploaded and seeded .iso files live."
Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/openpxe/isos</Config>
<Config Name="Work dir" Target="/var/lib/openpxe/work" Default="/mnt/user/appdata/openpxe/work"
Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/pxeforge/isos</Config>
<Config Name="Work dir" Target="/var/lib/pxeforge/work" Default="/mnt/user/appdata/pxeforge/work"
Mode="rw" Description="Settings, NFS state, and runtime scratch. Persisted across restarts."
Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/openpxe/work</Config>
<Config Name="SMB share root" Target="/var/lib/openpxe/smb" Default="/mnt/user/appdata/openpxe/smb"
Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/pxeforge/work</Config>
<Config Name="SMB share root" Target="/var/lib/pxeforge/smb" Default="/mnt/user/appdata/pxeforge/smb"
Mode="rw" Description="Where extracted Windows install media lives. Only used when Windows toggle is on."
Type="Path" Display="advanced" Required="false" Mask="false">/mnt/user/appdata/openpxe/smb</Config>
<Config Name="Public IP" Target="OPENPXE_PUBLIC_IP" Default=""
Type="Path" Display="advanced" Required="false" Mask="false">/mnt/user/appdata/pxeforge/smb</Config>
<Config Name="Public IP" Target="PXEFORGE_PUBLIC_IP" Default=""
Mode="" Description="IP advertised to PXE clients. Leave blank to auto-detect; set explicitly on multi-homed Unraid hosts."
Type="Variable" Display="always" Required="false" Mask="false"></Config>
<Config Name="Log filter" Target="OPENPXE_LOG" Default="info,openpxe=debug"
<Config Name="Log filter" Target="PXEFORGE_LOG" Default="info,pxeforge=debug"
Mode="" Description="tracing-subscriber EnvFilter expression."
Type="Variable" Display="advanced" Required="false" Mask="false">info,openpxe=debug</Config>
Type="Variable" Display="advanced" Required="false" Mask="false">info,pxeforge=debug</Config>
</Container>
+22 -22
View File
@@ -5,13 +5,13 @@
# 1. Local MVP test — host network, proxy-DHCP off (don't fight your
# existing DHCP server on the LAN), TFTP + HTTP exposed on the host:
#
# docker compose up openpxe-dev
# docker compose up pxeforge-dev
#
# 2. Real PXE deployment — host network, proxy-DHCP on, runs on a box
# plugged into the PXE network:
#
# # First set OPENPXE_PUBLIC_IP to this host's LAN address in .env
# docker compose up openpxe
# # First set PXEFORGE_PUBLIC_IP to this host's LAN address in .env
# docker compose up pxeforge
#
# On Linux hosts, `network_mode: host` gives the container direct access to
# the physical NIC — required for DHCP proxy because CNI overlays and Docker
@@ -19,13 +19,13 @@
#
# On macOS / Windows hosts, `network_mode: host` is limited — the daemon
# runs in a Linux VM (Colima/Docker Desktop) so the "host" network is the
# VM, not your Mac. Proxy-DHCP is not feasible on macOS; use `openpxe-dev`
# VM, not your Mac. Proxy-DHCP is not feasible on macOS; use `pxeforge-dev`
# with published ports and set DHCP-MODE=disabled.
services:
# Real PXE deployment (Linux hosts).
openpxe:
image: openpxe:0.1.0
pxeforge:
image: pxeforge:0.1.0
build:
context: .
dockerfile: deploy/docker/Dockerfile
@@ -34,33 +34,33 @@ services:
environment:
# REQUIRED on multi-homed hosts. Set to this machine's LAN IP so the
# advertised iPXE URLs actually resolve from the PXE clients. Without
# this, OpenPXE will refuse to start rather than advertise a
# this, PXEForge will refuse to start rather than advertise a
# loopback address that can't be reached.
OPENPXE_PUBLIC_IP: ${OPENPXE_PUBLIC_IP:?set this to the host LAN IP}
OPENPXE_DHCP_MODE: proxy
OPENPXE_LOG: info
PXEFORGE_PUBLIC_IP: ${PXEFORGE_PUBLIC_IP:?set this to the host LAN IP}
PXEFORGE_DHCP_MODE: proxy
PXEFORGE_LOG: info
volumes:
- ./data/isos:/var/lib/openpxe/isos
- ./data/work:/var/lib/openpxe/work
- ./data/isos:/var/lib/pxeforge/isos
- ./data/work:/var/lib/pxeforge/work
# Dev / MVP container: published ports, DHCP disabled, HTTP on 8080.
# Use this on laptops where you want to curl the API or UI without
# running an actual PXE chain.
openpxe-dev:
image: openpxe:0.1.0
pxeforge-dev:
image: pxeforge:0.1.0
build:
context: .
dockerfile: deploy/docker/Dockerfile
environment:
OPENPXE_PUBLIC_IP: ${OPENPXE_PUBLIC_IP:-127.0.0.1}
OPENPXE_DHCP_MODE: disabled
OPENPXE_HTTP_PORT: "8080"
OPENPXE_TFTP_PORT: "6969"
OPENPXE_DHCP_PORT: "6767"
OPENPXE_LOG: info,openpxe=debug
PXEFORGE_PUBLIC_IP: ${PXEFORGE_PUBLIC_IP:-127.0.0.1}
PXEFORGE_DHCP_MODE: disabled
PXEFORGE_HTTP_PORT: "8080"
PXEFORGE_TFTP_PORT: "6969"
PXEFORGE_DHCP_PORT: "6767"
PXEFORGE_LOG: info,pxeforge=debug
ports:
- "8080:8080/tcp"
- "6969:6969/udp"
volumes:
- ./data/isos:/var/lib/openpxe/isos
- ./data/work:/var/lib/openpxe/work
- ./data/isos:/var/lib/pxeforge/isos
- ./data/work:/var/lib/pxeforge/work
+10 -10
View File
@@ -1,7 +1,7 @@
# Phase 6 — recommendations
The v0.4.1 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 PXEForge 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
@@ -66,7 +66,7 @@ serve-side per request.
### 5. Wake-on-LAN trigger
A natural pair with per-MAC host bindings: bind a MAC to an image,
then click "Wake & Image" to send the magic packet and let OpenPXE
then click "Wake & Image" to send the magic packet and let PXEForge
do the rest. Implementation is small (`udp/9` broadcast, magic packet
construction) but it makes the bound-host workflow feel instant.
@@ -113,10 +113,10 @@ for silent breakage.
### 11. Multi-replica deployment
The current design assumes one OpenPXE per broadcast domain. Two
proxies on the same L2 will race; the deployment queue is in-memory, etc.
The current design assumes one PXEForge per broadcast domain. Two
proxies on the same L2 will race; the gate queue is in-memory, etc.
For HA we'd need to:
- Externalize the deployment queue (Redis, etcd) or lean into "the menu is
- Externalize the gate queue (Redis, etcd) or lean into "the menu is
cheap to refetch if a replica dies";
- Ensure DHCP proxy replies are deterministic so a client always
gets the same answer regardless of which replica replied;
@@ -128,7 +128,7 @@ asking for it.
### 12. Pi 4 / SBC quirks
Raspberry Pi netboot uses a specific DHCP option-43 vendor field +
TFTP path layout that OpenPXE doesn't currently special-case. There's
TFTP path layout that PXEForge doesn't currently special-case. There's
a spec; the work is small once we have a Pi to test on.
## What I'd skip
@@ -137,7 +137,7 @@ a spec; the work is small once we have a Pi to test on.
abstraction; full DHCP would need raw sockets + a lot of corner-case
handling for problems no operator wants us to solve.
- **A pluggable backend abstraction à la Tinkerbell.** Tinkerbell does
it because they integrate with k8s CRDs. OpenPXE's "the file system
it because they integrate with k8s CRDs. PXEForge's "the file system
IS the database" model is simpler and good enough for the target
audience. Don't add a Backend trait until something asks for it.
- **Multiple language UIs.** Bootimus added these in v0.1.62 and the
@@ -148,8 +148,8 @@ a spec; the work is small once we have a Pi to test on.
- Add a Grafana dashboard JSON to `deploy/grafana/` driven off the
new `/metrics` endpoint.
- A `openpxe bench` subcommand that runs a 10-second internal load
test (synthetic queue joins) so an operator can sanity-check tuning.
- A `pxeforge bench` subcommand that runs a 10-second internal load
test (synthetic gate joins) so an operator can sanity-check tuning.
- Ship a basic `docker-compose.yml` for the Unraid path that demos
the new themes / progress widget.
- Generate a printable single-page operator runbook from the README
+40 -39
View File
@@ -1,4 +1,4 @@
# OpenPXE architecture
# PXEForge architecture
## Protocol stack
@@ -8,7 +8,7 @@ Client firmware PXE ROM
│ DHCPDISCOVER (UDP/67 broadcast, option 60 "PXEClient", option 93 arch)
┌─────────────────────────────────────────────────────────────────────────┐
OpenPXE
PXEForge
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ DHCP proxy │ │ TFTP server │ │ HTTP server (axum) │ │
@@ -33,7 +33,7 @@ Client runs iPXE
│ DHCPDISCOVER with option 77 "iPXE"
OpenPXE sees user-class "iPXE" → replies with HTTP URL: /boot.ipxe
PXEForge sees user-class "iPXE" → replies with HTTP URL: /boot.ipxe
│ HTTP GET /boot.ipxe (iPXE menu, auto-generated from IsoStore)
@@ -48,14 +48,14 @@ Kernel boots with distro-specific args pointing back at /iso/<id>.iso
| Crate | Responsibility |
|---------------------|-------------------------------------------------------------------|
| `openpxe-core` | Shared types: `Config`, `ClientArch`, `FirmwareClass`, `ClientRegistry` |
| `openpxe-ipxe-assets` | Embeds bundled iPXE binaries via `rust-embed` |
| `openpxe-iso-store` | On-disk ISO store, introspection, boot-entry generation |
| `openpxe-dhcp-proxy` | UDP listener + `dhcproto` reply builder; pure `decide()` unit-testable |
| `openpxe-tftp` | RFC 1350 + OACK (blksize / tsize / windowsize). Serves only embedded assets — no filesystem |
| `openpxe-http-api` | `axum` router: web UI, API, iPXE script generation, ISO streaming |
| `openpxe-webui` | Single `index.html` served as static string |
| `openpxe` (bin) | Wires everything together, runs the three servers concurrently |
| `pxeforge-core` | Shared types: `Config`, `ClientArch`, `FirmwareClass`, `ClientRegistry` |
| `pxeforge-ipxe-assets` | Embeds bundled iPXE binaries via `rust-embed` |
| `pxeforge-iso-store` | On-disk ISO store, introspection, boot-entry generation |
| `pxeforge-dhcp-proxy` | UDP listener + `dhcproto` reply builder; pure `decide()` unit-testable |
| `pxeforge-tftp` | RFC 1350 + OACK (blksize / tsize / windowsize). Serves only embedded assets — no filesystem |
| `pxeforge-http-api` | `axum` router: web UI, API, iPXE script generation, ISO streaming |
| `pxeforge-webui` | Single `index.html` served as static string |
| `pxeforge` (bin) | Wires everything together, runs the three servers concurrently |
## Key decisions and why
@@ -75,11 +75,11 @@ so plain `SOCK_DGRAM` is enough.
1. Firmware PXE ROM sends DHCPDISCOVER with option 60 = `PXEClient`,
option 93 = arch.
2. OpenPXE replies with TFTP server + arch-specific iPXE binary
2. PXEForge replies with TFTP server + arch-specific iPXE binary
(`undionly.kpxe` for Legacy BIOS, `snponly.efi` for x86_64 UEFI, etc.).
3. Client TFTPs the iPXE binary and runs it.
4. iPXE does its own DHCP, setting option 77 (user-class) to `iPXE`.
5. OpenPXE detects the user-class and this time replies with an HTTP URL
5. PXEForge detects the user-class and this time replies with an HTTP URL
in option 67 pointing at `/boot.ipxe`.
6. iPXE fetches and executes that script, which chains the selected OS.
@@ -105,7 +105,7 @@ Whatever we do for Windows, we never:
- instruct users to enable `bcdedit /set testsigning on`
- install any certificate into the target's root/trust store
iVentoy's `httpdisk.sys` approach broke this rule. OpenPXE doesn't.
iVentoy's `httpdisk.sys` approach broke this rule. PXEForge doesn't.
### Linux ISO boot uses kernel+initrd extraction, not sanboot
@@ -157,13 +157,13 @@ release:
root-owned" problem that breaks ISO upload on standard Docker hosts.
- `/healthz` and `/readyz` split from `/api/status` — readyz fails if no
iPXE binaries are bundled, giving K8s probes a real signal.
- Startup aborts with a clear error if `OPENPXE_PUBLIC_IP` can't be
- Startup aborts with a clear error if `PXEFORGE_PUBLIC_IP` can't be
auto-detected (no more silent `127.0.0.1` advertisement).
- `scripts/fetch-ipxe.sh` fails non-zero if zero binaries download; the
Dockerfile uses arch-scoped paths (`x86_64-efi/snponly.efi` etc.).
**Windows boot plumbing** (new):
- `openpxe-iso-store::smb::SmbManager` supervises `smbd` on the Windows
- `pxeforge-iso-store::smb::SmbManager` supervises `smbd` on the Windows
toggle: `start` → spawn + write `smb.conf`; `reconcile` → SIGHUP on
share changes; `stop` → SIGTERM. `SmbState` surfaced to the UI for
visibility.
@@ -177,32 +177,32 @@ release:
- ISO sizes in menu labels (`[ 4376 MB]`), iVentoy format.
- `Reboot Computer` + `Exit and continue BIOS boot` in Tools menu.
- Number-key hotkeys (1..9) on boot entries, letter hotkeys on tools.
- Clients tab cross-joins the deployment queue so an operator sees "in queue #2"
- Clients tab cross-joins the gate queue so an operator sees "at gate #2"
or "assigned: ubuntu-linux" status inline.
**Developer ergonomics** (new):
- `openpxe seed --from <path>` CLI to import ISOs from a directory.
- `pxeforge seed --from <path>` CLI to import ISOs from a directory.
Same pipeline as web upload (slug, sha256, introspection, boot-entry).
- `docker-compose.yml` with `openpxe` (host network, real PXE) and
`openpxe-dev` (published ports, DHCP disabled, for API testing).
- `docker-compose.yml` with `pxeforge` (host network, real PXE) and
`pxeforge-dev` (published ports, DHCP disabled, for API testing).
**API cleanliness**:
- All timestamps now serialized as RFC 3339 strings (the `time` crate's
default 9-tuple broke browser `Date` parsing).
- Queue poll retains assignment until the operator releases it; if the
- Gate poll retains assignment until the operator releases it; if the
client's chain fails, it reuses the assignment instead of falling back
to the menu.
## Phase 4 — UI restructure + remote storage
The web UI was rebuilt around six tabs (Dashboard / Network / Queue /
The web UI was rebuilt around six tabs (Dashboard / Network / Forge Gate /
Storage / Terminal / About) inspired by the iVentoy layout the user
attached and Netbox Labs's compact-card pattern. The old hierarchical
"Monitoring / Content / Configuration" sidebar grouping is gone — every
tab is one click from the brand bar.
**NFS share manager** (`crates/iso-store/src/nfs.rs`):
- Operators add a remote share via Storage → NFS shares; OpenPXE mounts
- Operators add a remote share via Storage → NFS shares; PXEForge mounts
it under `<work_dir>/nfs/<id>/` and walks it for `*.iso` files.
- Each ISO found is registered with `IsoStore::register_external` using
a new `IsoSource::Nfs { mount_id, relative_path }` variant. The store
@@ -227,7 +227,7 @@ tab is one click from the brand bar.
followed by live updates. Slow clients see a `lagged` event rather
than dropping the stream.
- `/api/terminal` accepts a single command line and dispatches to a
whitelist (`status`, `isos`, `clients`, `queue {list,assign,release}`,
whitelist (`status`, `isos`, `clients`, `gate {list,assign,release}`,
`nfs {list,mount,unmount,scan}`, `smb {status,start,stop,reload}`,
`log {clear,tail}`). Output is mirrored onto the LogBus so reading the
live tail tells the same story as scrolling the terminal pane.
@@ -240,7 +240,7 @@ tab is one click from the brand bar.
read-only by design — silently changing the public IP on a hot UI
would break PXE for every client mid-boot.
- The only writable network field is `dns_server`, an optional
informational hint stored in `Settings`. OpenPXE does not run a DNS
informational hint stored in `Settings`. PXEForge does not run a DNS
server; the field exists so operators don't have to dig out the
upstream DNS at 3 AM.
@@ -252,7 +252,7 @@ tab is one click from the brand bar.
warning.
- Dashboard surfaces a "Images that won't boot" panel reusing the same
predicate, so the operator sees the problem before they pick the ISO
in the queue.
in the gate.
- Streaming uploads are already in place via axum multipart; the v0.1.62
fix to "502 on big upload" doesn't apply.
@@ -260,14 +260,15 @@ tab is one click from the brand bar.
**Per-MAC host bindings** (`crates/core/src/host_bindings.rs`):
- New `HostBindings` registry maps a MAC → preferred `BootEntry::id`
(or one of the reserved menu shortcuts (`_local`, `_queue``,
(or one of the reserved menu shortcuts `_local`, `_gate`,
`_tools_menu`).
- Persisted to `<work_dir>/hosts.json`. Like `SettingsStore`, in-memory
is authoritative — disk corruption falls back to empty rather than
failing startup.
- The DHCP reply embeds `?mac=${mac}` in the boot.ipxe URL; iPXE
substitutes the literal MAC client-side, so the HTTP layer can
short-circuit past the menu when a binding exists.
- Inspired by Tinkerbell `smee`'s MAC-prepended URL pattern. 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
past the menu when a binding exists.
- `/api/hosts` GET / POST / DELETE drives the **Hosts** tab.
**Prometheus metrics** (`crates/core/src/metrics.rs`):
@@ -277,7 +278,7 @@ tab is one click from the brand bar.
- Counters: DHCP replies (per arch label), DHCP declined, TFTP
transfers (per status label), TFTP bytes, HTTP requests (per route
label).
- Gauges: ISO count, client count, queue count, queue-imaging count,
- Gauges: ISO count, client count, gate count, gate-imaging count,
NFS active mounts, uptime, build info.
- Exposed as plain Prometheus text at `/metrics`.
@@ -287,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 Forge Gate: 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**:
@@ -315,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
+47 -47
View File
@@ -1,13 +1,13 @@
# Runbook: Boot a Linux machine from an ISO over the network
End-to-end walkthrough: spin up OpenPXE, load an Ubuntu (or any
End-to-end walkthrough: spin up PXEForge, load an Ubuntu (or any
Linux) ISO into it, target a specific bare-metal or VM client by its
MAC address, and have that machine PXE-boot the installer over the
LAN — no USB stick, no console babysitting.
This runbook assumes:
- You have **one Linux host** to run the OpenPXE container (any
- You have **one Linux host** to run the PXEForge container (any
distro with Docker / Podman; 2 GB RAM, ~50 GB disk for the ISO
library).
- That host sits on the **same broadcast domain / VLAN** as the
@@ -15,7 +15,7 @@ This runbook assumes:
networks need a DHCP relay and are out of scope here.
- An **existing DHCP server** is already handing out IP leases on
that VLAN (your home router, OPNsense, Windows Server, etc.).
OpenPXE runs as a *DHCP proxy* — it never leases IPs, it only
PXEForge runs as a *DHCP proxy* — it never leases IPs, it only
layers the boot information on top of the existing DHCP exchange.
- The target client is configured to **PXE-boot** in BIOS/UEFI
firmware (usually `F12` boot menu → Network, or set as first boot
@@ -28,7 +28,7 @@ or [docs/architecture.md](../docs/architecture.md) first.
## 0. Pick your hosts LAN IP
You need the IPv4 address OpenPXE will advertise to clients. From
You need the IPv4 address PXEForge will advertise to clients. From
the host:
```bash
@@ -40,33 +40,33 @@ example `10.0.0.5/24` on `eno1`. From here on we call it
`PXE_HOST_IP`.
> **Why this matters.** Every URL handed to clients (TFTP server,
> iPXE chain URL, ISO URL) is built from this IP. If OpenPXE
> iPXE chain URL, ISO URL) is built from this IP. If PXEForge
> auto-detects the wrong interface or loopback, clients will fetch
> from an unreachable address and silently fail. The startup will
> *fail loudly* if it can only auto-detect a loopback address.
---
## 1. Run OpenPXE
## 1. Run PXEForge
The MVP path is a single `docker run` against the published image,
with `--network host` so the container can see DHCP broadcasts on
the LAN.
```bash
mkdir -p ~/openpxe/isos ~/openpxe/work
mkdir -p ~/pxeforge/isos ~/pxeforge/work
docker run -d --name openpxe \
docker run -d --name pxeforge \
--restart unless-stopped \
--network host \
-e OPENPXE_PUBLIC_IP=10.0.0.5 \
-e OPENPXE_DHCP_MODE=proxy \
-v ~/openpxe/isos:/var/lib/openpxe/isos \
-v ~/openpxe/work:/var/lib/openpxe/work \
ghcr.io/YOUR-ORG/openpxe:0.2.0
-e PXEFORGE_PUBLIC_IP=10.0.0.5 \
-e PXEFORGE_DHCP_MODE=proxy \
-v ~/pxeforge/isos:/var/lib/pxeforge/isos \
-v ~/pxeforge/work:/var/lib/pxeforge/work \
ghcr.io/YOUR-ORG/pxeforge:0.2.0
```
Substitute your `OPENPXE_PUBLIC_IP`, of course. If youre building
Substitute your `PXEFORGE_PUBLIC_IP`, of course. If youre building
from this repo instead of pulling, see the
[README quick start](../README.md#quick-start--mvp-container-recommended).
@@ -84,7 +84,7 @@ otherwise. See [README — Container health probes](../README.md#container-healt
### Check the listening ports
OpenPXE holds three privileged UDP/TCP ports. From another shell on
PXEForge holds three privileged UDP/TCP ports. From another shell on
the host:
```bash
@@ -93,7 +93,7 @@ sudo ss -lntp | grep ':80\b' # HTTP UI / boot scripts
```
All four should be present. If port 67 is taken by `dnsmasq` or the
hosts own DHCP, stop that service or run OpenPXE on a separate box —
hosts own DHCP, stop that service or run PXEForge on a separate box —
two listeners on `:67` will fight.
---
@@ -114,8 +114,8 @@ Two options. Pick one.
- File size and SHA-256
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
gated by your browser ↔ host link. The UI shows a progress bar; the
animated anvil on the Dashboard tab fires up while imaging is in
flight.
### 2b. Bulk seed from a directory (recommended for fresh deploys / CI)
@@ -124,17 +124,17 @@ If you already have a folder of ISOs on the host, skip the browser:
```bash
# Dry run first — see what would be imported, no writes:
docker exec openpxe openpxe seed \
docker exec pxeforge pxeforge seed \
--from /seed \
--dry-run
# For real, mount the source dir read-only into the container:
docker run --rm \
-v /my/iso-library:/seed:ro \
-v ~/openpxe/isos:/var/lib/openpxe/isos \
-v ~/openpxe/work:/var/lib/openpxe/work \
-e OPENPXE_PUBLIC_IP=10.0.0.5 \
ghcr.io/YOUR-ORG/openpxe:0.2.0 seed --from /seed
-v ~/pxeforge/isos:/var/lib/pxeforge/isos \
-v ~/pxeforge/work:/var/lib/pxeforge/work \
-e PXEFORGE_PUBLIC_IP=10.0.0.5 \
ghcr.io/YOUR-ORG/pxeforge:0.2.0 seed --from /seed
```
Each `*.iso` in `/seed` runs through the same upload pipeline as the
@@ -182,13 +182,13 @@ Most BIOS/UEFI screens display the NIC MAC during the network-boot
attempt — usually as `MAC: AA-BB-CC-DD-EE-FF` flashing on the splash
right before "PXE-E53: No boot filename received". Write it down.
### 3c. By letting it boot once and watching OpenPXE
### 3c. By letting it boot once and watching PXEForge
Easiest if the box is in front of you:
1. Power on, hit `F12`, pick **Network boot**.
2. Without any binding configured, the client will land on the
OpenPXE menu (Default / Installers / Tools / Queued Deployment).
PXEForge menu (Default / Installers / Tools / Gated Deployment).
3. Dont pick anything. On your laptop:
```bash
curl -fsS http://10.0.0.5/api/clients | jq .
@@ -196,7 +196,7 @@ Easiest if the box is in front of you:
4. The most-recent entry is your target. Copy its `mac`.
From here on we call this MAC `TARGET_MAC` (e.g. `aa:bb:cc:dd:ee:ff`).
Hyphens vs colons, upper vs lower case — OpenPXE normalizes both.
Hyphens vs colons, upper vs lower case — PXEForge normalizes both.
---
@@ -227,7 +227,7 @@ curl -fsS -X POST http://10.0.0.5/api/hosts \
}' | jq .
```
The binding is persisted to `~/openpxe/work/hosts.json` and survives
The binding is persisted to `~/pxeforge/work/hosts.json` and survives
container restart.
### Confirm
@@ -262,7 +262,7 @@ In order, with timing:
| iPXE banner | ~1 s | The blue iPXE splash, version string |
| iPXE second-stage DHCP | ~1 s | `Configuring (net0 …)` then `ok` |
| HTTP boot script fetch | <1 s | `http://10.0.0.5/boot.ipxe?mac=…` |
| Per-MAC chain | <1 s | `OpenPXE: per-MAC binding -> ubuntu-24-04-1-…` |
| Per-MAC chain | <1 s | `PXEForge: per-MAC binding -> ubuntu-24-04-1-…` |
| Kernel + initrd HTTP | 530 s | Two 200-OK fetches against `/iso/<id>/casper/vmlinuz` and `…/initrd` |
| Kernel boot | 510 s | Kernel banner, then the Ubuntu/cloud-init splash |
| Installer comes up | 3060 s | The distros normal Live/installer environment |
@@ -281,16 +281,16 @@ curl -N http://10.0.0.5/api/log/stream
Youll see each protocol step as it happens:
```
INFO openpxe::dhcp: reply mac=aa:bb:cc:dd:ee:ff arch=X8664Uefi target=tftp/snponly.efi
INFO openpxe::tftp: RRQ snponly.efi blksize=1468 windowsize=8 → 982 KiB in 412 ms
INFO openpxe::dhcp: reply mac=aa:bb:cc:dd:ee:ff (iPXE) target=http/boot.ipxe
INFO openpxe::http: GET /boot.ipxe?mac=aa:bb:cc:dd:ee:ff → host binding hit
INFO openpxe::http: GET /iso/ubuntu-…/casper/vmlinuz Range=bytes=0- 200 OK 14 MiB
INFO openpxe::http: GET /iso/ubuntu-…/casper/initrd Range=bytes=0- 200 OK 75 MiB
INFO pxeforge::dhcp: reply mac=aa:bb:cc:dd:ee:ff arch=X8664Uefi target=tftp/snponly.efi
INFO pxeforge::tftp: RRQ snponly.efi blksize=1468 windowsize=8 → 982 KiB in 412 ms
INFO pxeforge::dhcp: reply mac=aa:bb:cc:dd:ee:ff (iPXE) target=http/boot.ipxe
INFO pxeforge::http: GET /boot.ipxe?mac=aa:bb:cc:dd:ee:ff → host binding hit
INFO pxeforge::http: GET /iso/ubuntu-…/casper/vmlinuz Range=bytes=0- 200 OK 14 MiB
INFO pxeforge::http: GET /iso/ubuntu-…/casper/initrd Range=bytes=0- 200 OK 75 MiB
```
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
@@ -300,14 +300,14 @@ boots from your LAN, but the underlying ISO can come from anywhere
your *host* can reach:
- **Direct upload** from a remote workstation via the web UI (HTTPS
reverse-proxied if you put OpenPXE behind nginx/Caddy).
reverse-proxied if you put PXEForge behind nginx/Caddy).
- **NFS mount** of a remote share — Sidebar → **Storage****NFS**
`nfs://files.lab.example.com/exports/isos`. Mounted ISOs show up in
the same list and are PXE-bootable directly without copying.
- **Pre-seed** from a CI job that `curl`s a vendor mirror and runs
`openpxe seed --from`.
`pxeforge seed --from`.
OpenPXE itself never reaches out to the internet at boot time — all
PXEForge itself never reaches out to the internet at boot time — all
client traffic stays on the LAN, served from the host.
---
@@ -337,29 +337,29 @@ curl -fsS -X POST http://10.0.0.5/api/hosts \
-d '{ "mac": "aa:bb:cc:dd:ee:ff", "target": "_local", "label": "lab-rack3-node07 (installed)" }'
```
Now if anyone hits `F12 → Network` by accident, OpenPXE replies
Now if anyone hits `F12 → Network` by accident, PXEForge replies
with a script that says *"chain back to local HDD"* and the box
boots its real OS instead of re-imaging itself. This is the safest
default for production hardware.
---
## 7. Re-imaging — the “Queued Deployment” flow
## 7. Re-imaging — the “Gated 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,
3. On each: select **Gated 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.
---
@@ -376,7 +376,7 @@ still in the kernel-fetch phase.
| Release binding | `DELETE /api/hosts/<mac>` |
| Live log | `curl -N http://$IP/api/log/stream` |
| Prometheus metrics | `curl http://$IP/metrics` |
| Bulk import folder | `openpxe seed --from /path` |
| Bulk import folder | `pxeforge seed --from /path` |
---
@@ -387,7 +387,7 @@ still in the kernel-fetch phase.
`--network host`, check the host firewall on UDP 67/69/4011.
- **iPXE shows `No more network devices`** — firmware NIC isnt in
PXE mode, or VLAN tagging is wrong.
- **iPXE prints `Connection timed out (http://…)`**`OPENPXE_PUBLIC_IP`
- **iPXE prints `Connection timed out (http://…)`**`PXEFORGE_PUBLIC_IP`
is wrong. Clients cant reach that IP. Check `/api/status`
`public_base_url` and `ping` it from the client subnet.
- **Kernel panics during initrd load** — corrupt ISO upload. Check
+9 -9
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# build-and-publish-unraid.sh — one-shot: clone OpenPXE, build the image,
# build-and-publish-unraid.sh — one-shot: clone PXEForge, build the image,
# push it to your local Gitea container registry. Run this ON the Unraid
# box (or any host that can reach Gitea on http://localhost:3000 or its
# LAN IP). No Cloudflare in the way; the proxy doesn't matter for this
@@ -10,31 +10,31 @@
# GITEA_HOST default: localhost:3000 (use 192.168.1.49:3000 if
# you're on the LAN but not on the Unraid host)
# GITEA_OWNER default: mward4
# GITEA_REPO default: OpenPXE
# GITEA_REPO default: PXEForge
# IMAGE_TAG default: 0.1.0 (also tagged :latest)
# PLATFORM default: linux/amd64 (Unraid is x86_64)
# WORKDIR default: /tmp/openpxe-build (deleted on success)
# WORKDIR default: /tmp/pxeforge-build (deleted on success)
#
# What it does:
# 1. git clone <gitea>/mward4/OpenPXE.git into WORKDIR
# 1. git clone <gitea>/mward4/PXEForge.git into WORKDIR
# 2. fetch iPXE binaries (scripts/fetch-ipxe.sh)
# 3. docker build deploy/docker/Dockerfile -> openpxe:$TAG (and :latest)
# 3. docker build deploy/docker/Dockerfile -> pxeforge:$TAG (and :latest)
# 4. docker login to GITEA_HOST using the token
# 5. docker push to <gitea>/<owner>/openpxe:<tag> and :latest
# 5. docker push to <gitea>/<owner>/pxeforge:<tag> and :latest
# 6. docker logout, scrub creds, clean WORKDIR
#
# After this, on any Unraid Docker template, set:
# Repository: <gitea>/mward4/openpxe:0.1.0 (or :latest)
# Repository: <gitea>/mward4/pxeforge:0.1.0 (or :latest)
# Network: host (DHCP/TFTP need raw L2)
set -euo pipefail
GITEA_HOST=${GITEA_HOST:-localhost:3000}
GITEA_OWNER=${GITEA_OWNER:-mward4}
GITEA_REPO=${GITEA_REPO:-OpenPXE}
GITEA_REPO=${GITEA_REPO:-PXEForge}
IMAGE_TAG=${IMAGE_TAG:-0.1.0}
PLATFORM=${PLATFORM:-linux/amd64}
WORKDIR=${WORKDIR:-/tmp/openpxe-build}
WORKDIR=${WORKDIR:-/tmp/pxeforge-build}
# Lowercase the image name — OCI distribution rejects uppercase paths.
IMAGE_NAME="$(printf '%s' "$GITEA_REPO" | tr '[:upper:]' '[:lower:]')"
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Fetch prebuilt iPXE binaries from the official distribution at
# https://boot.ipxe.org/ and place them under assets/ipxe/ with the filenames
# OpenPXE's arch mapping expects.
# PXEForge's arch mapping expects.
#
# Why not build from source?
# - Building iPXE requires the toolchain + several megabytes of source, and