Compare commits

...
5 Commits
Author SHA1 Message Date
Miles Ward 4e88305101 Name update 2026-05-06 14:14:09 -04:00
Miles Ward 20c585e3ed Name update 2026-05-06 14:13:38 -04:00
503432756 e1b7154b51 docs: add Linux network-boot runbook 2026-04-30 11:35:47 -04:00
Miles Ward 40064906de Name update 2026-04-30 02:30:05 -04:00
Miles Ward 49d0b00a8a 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
58 changed files with 2677 additions and 723 deletions
+1
View File
@@ -11,3 +11,4 @@ data/work/
# settings.local.json is your personal allowlist history and shouldn't be). # settings.local.json is your personal allowlist history and shouldn't be).
.claude/settings.local.json .claude/settings.local.json
.claude/worktrees/ .claude/worktrees/
.claude/scheduled_tasks.lock
+11 -11
View File
@@ -8,16 +8,16 @@ members = [
"crates/iso-store", "crates/iso-store",
"crates/ipxe-assets", "crates/ipxe-assets",
"crates/webui", "crates/webui",
"crates/pxeforge", "crates/openpxe",
] ]
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.3.0"
edition = "2021" edition = "2021"
rust-version = "1.80" rust-version = "1.80"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
repository = "https://github.com/casperadmin/PXEForge" repository = "https://gitea.milesward.dev/mward4/OpenPXE"
authors = ["PXEForge contributors"] authors = ["OpenPXE contributors"]
[workspace.dependencies] [workspace.dependencies]
tokio = { version = "1.40", features = ["full"] } tokio = { version = "1.40", features = ["full"] }
@@ -57,13 +57,13 @@ once_cell = "1.19"
parking_lot = "0.12" parking_lot = "0.12"
rust-embed = { version = "8.5", features = ["include-exclude"] } rust-embed = { version = "8.5", features = ["include-exclude"] }
pxeforge-core = { path = "crates/core" } openpxe-core = { path = "crates/core" }
pxeforge-dhcp-proxy = { path = "crates/dhcp-proxy" } openpxe-dhcp-proxy = { path = "crates/dhcp-proxy" }
pxeforge-tftp = { path = "crates/tftp" } openpxe-tftp = { path = "crates/tftp" }
pxeforge-http-api = { path = "crates/http-api" } openpxe-http-api = { path = "crates/http-api" }
pxeforge-iso-store = { path = "crates/iso-store" } openpxe-iso-store = { path = "crates/iso-store" }
pxeforge-ipxe-assets = { path = "crates/ipxe-assets" } openpxe-ipxe-assets = { path = "crates/ipxe-assets" }
pxeforge-webui = { path = "crates/webui" } openpxe-webui = { path = "crates/webui" }
[workspace.lints.rust] [workspace.lints.rust]
unsafe_code = "deny" unsafe_code = "deny"
+64 -53
View File
@@ -1,14 +1,16 @@
# PXEForge # OpenPXE
Container-native PXE boot server. A Rust reimplementation of Container-native PXE boot server. A Rust reimplementation of
[iVentoy (ventoy/PXE)](https://github.com/ventoy/PXE), designed from scratch [iVentoy (ventoy/PXE)](https://github.com/ventoy/PXE), designed from scratch
for Docker/OCI and OpenShift. Upload `.iso` files via the web UI; network for Docker/OCI and OpenShift. Upload `.iso` files via the web UI; network
clients PXE-boot them. clients PXE-boot them.
> **Status:** Phase 3 MVP. Container image builds and runs, gate flow > **Status:** v0.2.0 / pre-beta. Phases 15 complete: full PXE stack,
> validated end-to-end (two clients join queue → operator assigns in UI → > Queued Deployment queue, NFS-share ISO sources, live tracing log + an
> both wake within 1 s with the correct boot script). Ready for real > operator terminal, per-MAC host bindings (Tinkerbell-style),
> hardware validation. > Prometheus `/metrics`, light/dark theme toggle, animated anvil
> imaging-progress widget. **66 tests passing**, clippy clean. Ready
> for real-hardware validation.
## Design non-negotiables ## Design non-negotiables
@@ -36,18 +38,27 @@ clients PXE-boot them.
``` ```
Default > Boot from Local HDD Default > Boot from Local HDD
Installers > Linux Installers / Windows Installers Installers > Linux Installers / Windows Installers
Tools > Utilities / PXEForge Shell / Network Card Info Tools > Utilities / OpenPXE Shell / Network Card Info
Gated Deployment Queued Deployment
``` ```
6. **Gated Deployment queue** — the "horse race gate" flow. A client that 6. **Queued Deployment queue** — the "horse race" launch flow. A client that
selects *Gated Deployment* gets a numbered position and waits. The selects *Queued Deployment* gets a numbered position and waits. The
operator picks an ISO in the web UI and fires it to every waiting operator picks an ISO in the web UI and fires it to every waiting
client simultaneously. client simultaneously.
7. **Web UI** (Netbox-style): sidebar nav (Dashboard / Clients / Gated 7. **Web UI** (Netbox-style): sidebar nav (Dashboard / Network / Forge
Deployment / Images / Settings / About), top tabs, dark theme, teal Gate / Storage / Hosts / Terminal / About), light + dark themes
accents. All assets served from the binary — no external requests. (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. 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 /
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 8. **Settings API** lets you change the default boot-menu timeout (default
600s), the timeout action (stay / Local HDD / Gated Deployment), and 600s), the timeout action (stay / Local HDD / Queued Deployment), and
feature toggles like Windows ISO support. The iPXE scripts regenerate feature toggles like Windows ISO support. The iPXE scripts regenerate
on every request using current settings. on every request using current settings.
@@ -70,17 +81,17 @@ skip TFTP and respond with an HTTP URL.
./scripts/fetch-ipxe.sh ./scripts/fetch-ipxe.sh
# 2. Build the container image (~3 min first time). # 2. Build the container image (~3 min first time).
docker buildx build -f deploy/docker/Dockerfile -t pxeforge:0.1.0 --load . docker buildx build -f deploy/docker/Dockerfile -t openpxe:0.1.0 --load .
# 3. Run it on the box plugged into your PXE network. Set PUBLIC_IP to # 3. Run it on the box plugged into your PXE network. Set PUBLIC_IP to
# this host's LAN address so advertised iPXE URLs are reachable. # this host's LAN address so advertised iPXE URLs are reachable.
docker run -d --name pxeforge \ docker run -d --name openpxe \
--network host \ --network host \
-e PXEFORGE_PUBLIC_IP=10.0.0.5 \ -e OPENPXE_PUBLIC_IP=10.0.0.5 \
-e PXEFORGE_DHCP_MODE=proxy \ -e OPENPXE_DHCP_MODE=proxy \
-v $PWD/data/isos:/var/lib/pxeforge/isos \ -v $PWD/data/isos:/var/lib/openpxe/isos \
-v $PWD/data/work:/var/lib/pxeforge/work \ -v $PWD/data/work:/var/lib/openpxe/work \
pxeforge:0.1.0 openpxe:0.1.0
# 4. Open the UI and drop an ISO in. # 4. Open the UI and drop an ISO in.
open http://10.0.0.5 open http://10.0.0.5
@@ -88,16 +99,16 @@ open http://10.0.0.5
Host networking is required in proxy mode so the container sees DHCPDISCOVER 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 broadcasts from the PXE VLAN. On macOS/Windows hosts Docker runs in a Linux
VM, so "host" means the VM — use `pxeforge-dev` in `docker-compose.yml` for VM, so "host" means the VM — use `openpxe-dev` in `docker-compose.yml` for
API-only testing on a laptop. API-only testing on a laptop.
### Quick start — docker compose ### Quick start — docker compose
```bash ```bash
# MVP / API testing on a laptop (no DHCP, high ports): # MVP / API testing on a laptop (no DHCP, high ports):
PXEFORGE_PUBLIC_IP=127.0.0.1 docker compose up pxeforge-dev OPENPXE_PUBLIC_IP=127.0.0.1 docker compose up openpxe-dev
# Real PXE deployment on a Linux host (host network, DHCP proxy on): # Real PXE deployment on a Linux host (host network, DHCP proxy on):
PXEFORGE_PUBLIC_IP=10.0.0.5 docker compose up pxeforge OPENPXE_PUBLIC_IP=10.0.0.5 docker compose up openpxe
``` ```
### Multi-arch build + push ### Multi-arch build + push
@@ -106,12 +117,12 @@ For deploying to x86_64 servers, build both arches in one manifest:
```bash ```bash
# One-time: bootstrap a multi-arch builder. # One-time: bootstrap a multi-arch builder.
docker buildx create --name pxeforge-multi --driver docker-container --use docker buildx create --name openpxe-multi --driver docker-container --use
# Build + push both linux/amd64 and linux/arm64 under one tag. # Build + push both linux/amd64 and linux/arm64 under one tag.
docker buildx build --builder pxeforge-multi \ docker buildx build --builder openpxe-multi \
--platform linux/amd64,linux/arm64 \ --platform linux/amd64,linux/arm64 \
-t ghcr.io/YOUR-ORG/pxeforge:0.1.0 \ -t ghcr.io/YOUR-ORG/openpxe:0.1.0 \
--push \ --push \
-f deploy/docker/Dockerfile . -f deploy/docker/Dockerfile .
``` ```
@@ -142,31 +153,31 @@ same pipeline the web UI uses (introspection + boot-entry generation):
```bash ```bash
docker run --rm \ docker run --rm \
-v /my/iso-library:/seed:ro \ -v /my/iso-library:/seed:ro \
-v pxeforge-data:/var/lib/pxeforge/isos \ -v openpxe-data:/var/lib/openpxe/isos \
-e PXEFORGE_PUBLIC_IP=10.0.0.5 \ -e OPENPXE_PUBLIC_IP=10.0.0.5 \
pxeforge:0.1.0 seed --from /seed openpxe:0.1.0 seed --from /seed
# Dry run first to see what would be imported: # Dry run first to see what would be imported:
docker run --rm -v /my/iso-library:/seed:ro pxeforge:0.1.0 seed --from /seed --dry-run docker run --rm -v /my/iso-library:/seed:ro openpxe:0.1.0 seed --from /seed --dry-run
``` ```
### Environment overrides ### Environment overrides
| Var | Default | Meaning | | Var | Default | Meaning |
|------------------------|-----------------------------|----------------------------------------| |------------------------|-----------------------------|----------------------------------------|
| `PXEFORGE_HTTP_PORT` | `80` | Web UI + boot script HTTP port | | `OPENPXE_HTTP_PORT` | `80` | Web UI + boot script HTTP port |
| `PXEFORGE_TFTP_PORT` | `69` | TFTP port | | `OPENPXE_TFTP_PORT` | `69` | TFTP port |
| `PXEFORGE_DHCP_PORT` | `67` | DHCP server-side port | | `OPENPXE_DHCP_PORT` | `67` | DHCP server-side port |
| `PXEFORGE_DHCP_MODE` | `proxy` | `proxy` or `disabled` | | `OPENPXE_DHCP_MODE` | `proxy` | `proxy` or `disabled` |
| `PXEFORGE_PUBLIC_IP` | auto-detect | Advertised IP for clients. Startup **fails** if unset and auto-detect returns loopback. | | `OPENPXE_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 | | `OPENPXE_ISO_DIR` | `/var/lib/openpxe/isos` | Where uploaded ISOs live |
| `PXEFORGE_WORK_DIR` | `/var/lib/pxeforge/work` | Scratch + runtime settings | | `OPENPXE_WORK_DIR` | `/var/lib/openpxe/work` | Scratch + runtime settings |
| `PXEFORGE_LOG` | `info,pxeforge=debug` | `tracing` filter | | `OPENPXE_LOG` | `info,openpxe=debug` | `tracing` filter |
## What the boot menu looks like on a real client ## What the boot menu looks like on a real client
``` ```
PXEForge - network boot menu OpenPXE - network boot menu
------------------------- Default ------------------------- ------------------------- Default -------------------------
Boot from Local HDD Boot from Local HDD
@@ -177,14 +188,14 @@ docker run --rm -v /my/iso-library:/seed:ro pxeforge:0.1.0 seed --from /seed --d
Tools > Utilities / Shell / Tools > Utilities / Shell /
NIC Info / Reboot / NIC Info / Reboot /
Exit and continue BIOS Exit and continue BIOS
---------------------- Gated Deployment ------------------ ---------------------- Queued Deployment ------------------
Gated Deployment (join queue) Queued Deployment (join queue)
``` ```
Linux/Windows submenus show file sizes iVentoy-style: Linux/Windows submenus show file sizes iVentoy-style:
``` ```
PXEForge - Linux Installers OpenPXE - Linux Installers
[ 4376 MB] CentOS-7-x86_64-DVD-1810 [ 4376 MB] CentOS-7-x86_64-DVD-1810
[ 2002 MB] Fedora-Workstation-Live-x86_64-38-1.6 [ 2002 MB] Fedora-Workstation-Live-x86_64-38-1.6
@@ -199,8 +210,8 @@ ISOs you upload via drag-and-drop in the web UI plus toggles in Settings.
```bash ```bash
oc apply -f deploy/openshift/ oc apply -f deploy/openshift/
oc -n pxeforge get all oc -n openpxe get all
oc -n pxeforge get route pxeforge -o jsonpath='{.spec.host}' oc -n openpxe get route openpxe -o jsonpath='{.spec.host}'
``` ```
### Why a custom SCC? ### Why a custom SCC?
@@ -208,7 +219,7 @@ oc -n pxeforge get route pxeforge -o jsonpath='{.spec.host}'
The default `restricted-v2` blocks `hostNetwork` and all capabilities. PXE The default `restricted-v2` blocks `hostNetwork` and all capabilities. PXE
cannot work without host network (CNI overlays don't deliver L2 broadcast 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 into pod netns), and we need `NET_BIND_SERVICE` to bind <1024. The custom
`pxeforge-scc` grants exactly those two and nothing else. No raw sockets, `openpxe-scc` grants exactly those two and nothing else. No raw sockets,
no privileged mode — proxy-mode DHCP sidesteps the usual requirements. no privileged mode — proxy-mode DHCP sidesteps the usual requirements.
### What's on host ports ### What's on host ports
@@ -228,7 +239,7 @@ the node's host IP directly for UDP.
Enabled by toggling **Windows ISO support** under Settings. The flow: Enabled by toggling **Windows ISO support** under Settings. The flow:
1. Upload a stock Microsoft Windows install ISO (vanilla, no pre-processing). 1. Upload a stock Microsoft Windows install ISO (vanilla, no pre-processing).
2. On upload, PXEForge extracts the ISO and uses `wimlib-imagex` to rewrite 2. On upload, OpenPXE extracts the ISO and uses `wimlib-imagex` to rewrite
image index 2 (WinPE) of `sources/boot.wim`. It injects exactly two image index 2 (WinPE) of `sources/boot.wim`. It injects exactly two
plain-text files: plain-text files:
- `Windows/System32/winpeshl.ini` — tells WinPE to run `startnet.cmd`. - `Windows/System32/winpeshl.ini` — tells WinPE to run `startnet.cmd`.
@@ -260,22 +271,22 @@ operational constraints inherited from the design:
- Hardware with NICs/storage controllers missing from WinPE's bundled - Hardware with NICs/storage controllers missing from WinPE's bundled
drivers will need a driver-pack injection step (not yet implemented). drivers will need a driver-pack injection step (not yet implemented).
## Gated Deployment ## Queued Deployment
The "horse race gate" flow, end to end: The "horse race" launch flow, end to end:
1. A client boots and picks **Gated Deployment** in the PXE menu (or falls 1. A client boots and picks **Queued Deployment** in the PXE menu (or falls
through on timeout with the default `timeout_action`). through on timeout with the default `timeout_action`).
2. The client joins the queue, gets a numbered gate position, and enters a 2. The client joins the queue, gets a numbered queue position, and enters a
long-poll loop (25s per request, auto-renewed). long-poll loop (25s per request, auto-renewed).
3. In the web UI's **Gated Deployment** tab, the operator sees each waiting 3. In the web UI's **Queued Deployment** tab, the operator sees each waiting
client with its MAC, IP, arch, and position. client with its MAC, IP, arch, and position.
4. The operator selects an image and clicks **Launch for all waiting**. 4. The operator selects an image and clicks **Launch for all waiting**.
The server broadcasts the assignment to every gated client via a The server broadcasts the assignment to every queued client via a
`tokio::sync::Notify`; each client's next poll returns the boot script `tokio::sync::Notify`; each client's next poll returns the boot script
for the chosen image. for the chosen image.
5. Every client chains the same image at effectively the same moment — the 5. Every client chains the same image at effectively the same moment — the
gate opens and the horses run together. queue releases and the horses run together.
No user-facing iPXE anywhere in this flow. The client only ever runs No user-facing iPXE anywhere in this flow. The client only ever runs
scripts we generate; the operator only interacts with the web UI. scripts we generate; the operator only interacts with the web UI.
+2 -2
View File
@@ -1,10 +1,10 @@
[package] [package]
name = "pxeforge-core" name = "openpxe-core"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
description = "Shared types, config, and arch detection for PXEForge" description = "Shared types, config, and arch detection for OpenPXE"
[lints] [lints]
workspace = true workspace = true
+1 -2
View File
@@ -49,9 +49,8 @@ impl ClientArch {
// ARM32 UEFI: upstream boot.ipxe.org does not publish a prebuilt // ARM32 UEFI: upstream boot.ipxe.org does not publish a prebuilt
// snponly variant for this arch. We return None so the DHCP // snponly variant for this arch. We return None so the DHCP
// proxy declines rather than advertising a file we can't serve. // proxy declines rather than advertising a file we can't serve.
Self::Arm32Uefi => return None, Self::Arm32Uefi | Self::Unknown(_) => return None,
Self::Arm64Uefi => "snponly-arm64.efi", Self::Arm64Uefi => "snponly-arm64.efi",
Self::Unknown(_) => return None,
}) })
} }
+2 -1
View File
@@ -89,7 +89,8 @@ impl ClientRegistry {
pub fn list(&self) -> Vec<ClientSnapshot> { pub fn list(&self) -> Vec<ClientSnapshot> {
let guard = self.inner.read(); let guard = self.inner.read();
let mut v: Vec<_> = guard.values().cloned().collect(); let mut v: Vec<_> = guard.values().cloned().collect();
v.sort_by(|a, b| b.last_seen.cmp(&a.last_seen)); // Reverse-chronological by last-seen (most recent first).
v.sort_by_key(|c| std::cmp::Reverse(c.last_seen));
v v
} }
+20 -26
View File
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr}; use std::net::{IpAddr, Ipv4Addr};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct Config { pub struct Config {
pub server: ServerConfig, pub server: ServerConfig,
@@ -54,7 +54,7 @@ pub enum DhcpMode {
#[default] #[default]
Proxy, Proxy,
/// Disabled — rely on an external DHCP server that has been manually /// Disabled — rely on an external DHCP server that has been manually
/// configured with `next-server` / `filename`. PXEForge only serves TFTP /// configured with `next-server` / `filename`. OpenPXE only serves TFTP
/// + HTTP in this mode. Useful for home routers that can be pre-set. /// + HTTP in this mode. Useful for home routers that can be pre-set.
Disabled, Disabled,
} }
@@ -72,7 +72,7 @@ pub struct Paths {
pub wimboot_path: Option<PathBuf>, pub wimboot_path: Option<PathBuf>,
/// Directory under which Windows ISOs are extracted and served via SMB. /// Directory under which Windows ISOs are extracted and served via SMB.
/// Only used when `settings.windows_enabled = true`. Defaults to /// Only used when `settings.windows_enabled = true`. Defaults to
/// `/var/lib/pxeforge/smb` in the container image. /// `/var/lib/openpxe/smb` in the container image.
pub smb_dir: PathBuf, pub smb_dir: PathBuf,
} }
@@ -104,24 +104,18 @@ impl Default for NetworkConfig {
impl Default for Paths { impl Default for Paths {
fn default() -> Self { fn default() -> Self {
Self { Self {
iso_dir: PathBuf::from("/var/lib/pxeforge/isos"), iso_dir: PathBuf::from("/var/lib/openpxe/isos"),
work_dir: PathBuf::from("/var/lib/pxeforge/work"), work_dir: PathBuf::from("/var/lib/openpxe/work"),
ipxe_dir: PathBuf::from("/usr/share/pxeforge/ipxe"), ipxe_dir: PathBuf::from("/usr/share/openpxe/ipxe"),
wimboot_path: None, wimboot_path: None,
smb_dir: PathBuf::from("/var/lib/pxeforge/smb"), smb_dir: PathBuf::from("/var/lib/openpxe/smb"),
} }
} }
} }
impl Default for Config { // `Config` derives `Default` because each component supplies its own
fn default() -> Self { // non-trivial defaults via `impl Default` blocks above; deriving keeps
Self { // this in sync if a new section is added.
server: ServerConfig::default(),
network: NetworkConfig::default(),
paths: Paths::default(),
}
}
}
impl Config { impl Config {
pub fn from_toml_file(path: &Path) -> crate::Result<Self> { pub fn from_toml_file(path: &Path) -> crate::Result<Self> {
@@ -130,38 +124,38 @@ impl Config {
} }
/// Apply environment variable overrides. Env var names follow the pattern /// Apply environment variable overrides. Env var names follow the pattern
/// `PXEFORGE_<SECTION>_<FIELD>`, uppercase. Unknown vars are ignored. /// `OPENPXE_<SECTION>_<FIELD>`, uppercase. Unknown vars are ignored.
/// Call this after loading the TOML file so env takes precedence. /// Call this after loading the TOML file so env takes precedence.
pub fn apply_env(&mut self) { pub fn apply_env(&mut self) {
if let Ok(v) = std::env::var("PXEFORGE_HTTP_PORT") { if let Ok(v) = std::env::var("OPENPXE_HTTP_PORT") {
if let Ok(p) = v.parse() { self.server.http_port = p; } if let Ok(p) = v.parse() { self.server.http_port = p; }
} }
if let Ok(v) = std::env::var("PXEFORGE_TFTP_PORT") { if let Ok(v) = std::env::var("OPENPXE_TFTP_PORT") {
if let Ok(p) = v.parse() { self.server.tftp_port = p; } if let Ok(p) = v.parse() { self.server.tftp_port = p; }
} }
if let Ok(v) = std::env::var("PXEFORGE_DHCP_PORT") { if let Ok(v) = std::env::var("OPENPXE_DHCP_PORT") {
if let Ok(p) = v.parse() { self.network.dhcp_port = p; } if let Ok(p) = v.parse() { self.network.dhcp_port = p; }
} }
if let Ok(v) = std::env::var("PXEFORGE_PUBLIC_IP") { if let Ok(v) = std::env::var("OPENPXE_PUBLIC_IP") {
if let Ok(ip) = v.parse() { self.server.public_ip = Some(ip); } if let Ok(ip) = v.parse() { self.server.public_ip = Some(ip); }
} }
if let Ok(v) = std::env::var("PXEFORGE_DHCP_MODE") { if let Ok(v) = std::env::var("OPENPXE_DHCP_MODE") {
self.network.dhcp_mode = match v.to_ascii_lowercase().as_str() { self.network.dhcp_mode = match v.to_ascii_lowercase().as_str() {
"proxy" => DhcpMode::Proxy, "proxy" => DhcpMode::Proxy,
"disabled" | "off" | "none" => DhcpMode::Disabled, "disabled" | "off" | "none" => DhcpMode::Disabled,
_ => self.network.dhcp_mode, _ => self.network.dhcp_mode,
}; };
} }
if let Ok(v) = std::env::var("PXEFORGE_ISO_DIR") { if let Ok(v) = std::env::var("OPENPXE_ISO_DIR") {
self.paths.iso_dir = PathBuf::from(v); self.paths.iso_dir = PathBuf::from(v);
} }
if let Ok(v) = std::env::var("PXEFORGE_WORK_DIR") { if let Ok(v) = std::env::var("OPENPXE_WORK_DIR") {
self.paths.work_dir = PathBuf::from(v); self.paths.work_dir = PathBuf::from(v);
} }
if let Ok(v) = std::env::var("PXEFORGE_IPXE_DIR") { if let Ok(v) = std::env::var("OPENPXE_IPXE_DIR") {
self.paths.ipxe_dir = PathBuf::from(v); self.paths.ipxe_dir = PathBuf::from(v);
} }
if let Ok(v) = std::env::var("PXEFORGE_SMB_DIR") { if let Ok(v) = std::env::var("OPENPXE_SMB_DIR") {
self.paths.smb_dir = PathBuf::from(v); self.paths.smb_dir = PathBuf::from(v);
} }
} }
+235
View File
@@ -0,0 +1,235 @@
//! Per-MAC host bindings.
//!
//! 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"
//! - "Tom's laptop always boots from local disk"
//! - "Bench QA machines always boot Memtest until released"
//!
//! Persisted to `<work_dir>/hosts.json`. Like the SettingsStore, on-disk
//! corruption falls back to an empty registry rather than failing
//! startup — a bad hosts file should never block PXE for the network.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use time::OffsetDateTime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostBinding {
/// Lowercase, colon-separated MAC (e.g. `aa:bb:cc:dd:ee:ff`). The
/// HTTP layer normalizes incoming MACs before lookup so callers
/// 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`, `_gate`,
/// `_tools_menu`. Empty string falls back to the menu.
pub target: String,
/// Optional human-readable label shown in the UI (`"Tom's laptop"`,
/// `"rack-3 spine"`). Empty if unset.
#[serde(default)]
pub label: String,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
}
#[derive(Debug, Default)]
struct Inner {
by_mac: HashMap<String, HostBinding>,
}
/// Registry of per-MAC bindings. Cheap to clone; locks are held
/// briefly. Persistence is best-effort and mirrors `SettingsStore`'s
/// "in-memory authoritative, disk is a cache" policy.
#[derive(Debug, Clone)]
pub struct HostBindings {
path: Arc<PathBuf>,
inner: Arc<RwLock<Inner>>,
}
impl HostBindings {
/// Load from `work_dir/hosts.json`, or start empty if absent /
/// unreadable.
#[must_use]
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("hosts.json");
let inner = match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str::<Vec<HostBinding>>(&text) {
Ok(items) => {
let mut by_mac = HashMap::new();
for b in items {
by_mac.insert(normalize_mac(&b.mac), b);
}
Inner { by_mac }
}
Err(e) => {
tracing::warn!(
target: "openpxe::hosts",
"hosts.json present but unreadable ({e}); starting empty"
);
Inner::default()
}
},
Err(_) => Inner::default(),
};
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(inner)),
}
}
/// Look up a binding by MAC. Match is case-insensitive and tolerates
/// `-` or `:` separators.
#[must_use]
pub fn lookup(&self, mac: &str) -> Option<HostBinding> {
self.inner.read().by_mac.get(&normalize_mac(mac)).cloned()
}
/// Insert or update. Returns the resulting binding (with timestamps).
pub fn upsert(&self, mac: &str, target: &str, label: &str) -> HostBinding {
let key = normalize_mac(mac);
let now = OffsetDateTime::now_utc();
let binding = {
let mut g = self.inner.write();
let entry = g.by_mac.entry(key.clone()).or_insert_with(|| HostBinding {
mac: key.clone(),
target: target.to_string(),
label: label.to_string(),
created_at: now,
updated_at: now,
});
entry.target = target.to_string();
entry.label = label.to_string();
entry.updated_at = now;
entry.clone()
};
self.persist();
binding
}
/// Remove a binding. Returns true if something was removed.
pub fn remove(&self, mac: &str) -> bool {
let key = normalize_mac(mac);
let removed = self.inner.write().by_mac.remove(&key).is_some();
if removed {
self.persist();
}
removed
}
#[must_use]
pub fn list(&self) -> Vec<HostBinding> {
let g = self.inner.read();
let mut v: Vec<_> = g.by_mac.values().cloned().collect();
v.sort_by(|a, b| a.mac.cmp(&b.mac));
v
}
#[must_use]
pub fn len(&self) -> usize {
self.inner.read().by_mac.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
fn persist(&self) {
let items: Vec<HostBinding> = self.inner.read().by_mac.values().cloned().collect();
let body = match serde_json::to_vec_pretty(&items) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::hosts", "serialize hosts.json: {e}");
return;
}
};
let tmp = self.path.with_extension("json.tmp");
if let Some(parent) = self.path.parent() {
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}");
return;
}
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::warn!(target: "openpxe::hosts", "rename hosts.json: {e}");
}
}
}
/// Lowercase a MAC and normalise `-` separators to `:`. We never strip
/// the separator entirely — `aabbccddeeff` formats are rejected at the
/// HTTP layer because they're ambiguous (could be a device id).
#[must_use]
pub fn normalize_mac(mac: &str) -> String {
mac.trim().to_ascii_lowercase().replace('-', ":")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn normalize_handles_case_and_dashes() {
assert_eq!(normalize_mac("AA:BB:CC:DD:EE:FF"), "aa:bb:cc:dd:ee:ff");
assert_eq!(normalize_mac("aa-bb-cc-dd-ee-ff"), "aa:bb:cc:dd:ee:ff");
assert_eq!(normalize_mac(" aA-Bb-CC:DD-ee:fF "), "aa:bb:cc:dd:ee:ff");
}
#[test]
fn upsert_then_lookup() {
let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path());
assert!(h.is_empty());
h.upsert("AA:BB:CC:00:00:01", "ubuntu-24-04-linux", "rack-3 spine");
let found = h.lookup("aa-bb-cc-00-00-01").expect("lookup");
assert_eq!(found.target, "ubuntu-24-04-linux");
assert_eq!(found.label, "rack-3 spine");
assert_eq!(found.mac, "aa:bb:cc:00:00:01");
}
#[test]
fn upsert_replaces_existing_target() {
let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path());
h.upsert("aa:bb:cc:00:00:01", "old-target", "label1");
h.upsert("aa:bb:cc:00:00:01", "new-target", "label2");
assert_eq!(h.len(), 1);
let b = h.lookup("aa:bb:cc:00:00:01").unwrap();
assert_eq!(b.target, "new-target");
assert_eq!(b.label, "label2");
}
#[test]
fn remove_works_and_reports_outcome() {
let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path());
h.upsert("aa:bb:cc:00:00:01", "x", "");
assert!(h.remove("AA:BB:CC:00:00:01"));
assert!(!h.remove("aa:bb:cc:00:00:01")); // already gone
assert!(h.is_empty());
}
#[test]
fn round_trip_persists_to_disk() {
let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path());
h.upsert("aa:bb:cc:00:00:01", "ubuntu-linux", "rack-3");
h.upsert("aa:bb:cc:00:00:02", "_local", "tom-laptop");
drop(h);
let h2 = HostBindings::load_or_default(dir.path());
assert_eq!(h2.len(), 2);
assert_eq!(h2.lookup("aa:bb:cc:00:00:02").unwrap().target, "_local");
}
}
+8 -4
View File
@@ -1,19 +1,23 @@
//! PXEForge shared core: config, arch detection, client state registry, //! OpenPXE shared core: config, arch detection, client state registry,
//! runtime settings, and the Gated Deployment queue. //! runtime settings, and the Queued Deployment queue.
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
pub mod arch; pub mod arch;
pub mod client; pub mod client;
pub mod config; pub mod config;
pub mod error; pub mod error;
pub mod gate; pub mod queue;
pub mod host_bindings;
pub mod log_bus; pub mod log_bus;
pub mod metrics;
pub mod settings; pub mod settings;
pub use arch::{ClientArch, FirmwareClass}; pub use arch::{ClientArch, FirmwareClass};
pub use client::{ClientEvent, ClientRegistry, ClientSnapshot}; pub use client::{ClientEvent, ClientRegistry, ClientSnapshot};
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig}; pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
pub use error::{Error, Result}; pub use error::{Error, Result};
pub use gate::{Gate, GateQueue}; pub use queue::{Gate, DeploymentQueue};
pub use host_bindings::{normalize_mac, HostBinding, HostBindings};
pub use log_bus::{LogBus, LogBusLayer, LogLine}; pub use log_bus::{LogBus, LogBusLayer, LogLine};
pub use metrics::{HttpRoute, Metrics};
pub use settings::{Settings, SettingsStore, TimeoutAction}; pub use settings::{Settings, SettingsStore, TimeoutAction};
+1 -1
View File
@@ -38,7 +38,7 @@ impl LogLine {
/// Compact one-line "tail -f"-style render. /// Compact one-line "tail -f"-style render.
#[must_use] #[must_use]
pub fn render(&self) -> String { pub fn render(&self) -> String {
// 2026-04-29T12:34:56Z [info] pxeforge::http: HTTP listening on 0.0.0.0:80 // 2026-04-29T12:34:56Z [info] openpxe::http: HTTP listening on 0.0.0.0:80
let ts = self let ts = self
.timestamp .timestamp
.format(&time::format_description::well_known::Rfc3339) .format(&time::format_description::well_known::Rfc3339)
+283
View File
@@ -0,0 +1,283 @@
//! 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,
//! 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)
//!
//! Cheap to clone — internal state is a couple of arcs. Counters use
//! `Relaxed` ordering: we don't synchronise across counters, just need
//! per-counter monotonicity.
use std::fmt::Write as _;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
#[derive(Debug, Default)]
#[allow(clippy::struct_field_names)]
struct Inner {
// DHCP proxy
dhcp_replies_legacy: AtomicU64,
dhcp_replies_uefi: AtomicU64,
dhcp_replies_arm64: AtomicU64,
dhcp_replies_unknown: AtomicU64,
dhcp_declined: AtomicU64,
// TFTP
tftp_transfers_ok: AtomicU64,
tftp_transfers_err: AtomicU64,
tftp_bytes: AtomicU64,
// HTTP
http_boot_script: AtomicU64,
http_iso_range: AtomicU64,
http_iso_inner: AtomicU64,
http_ipxe_binary: AtomicU64,
http_api: AtomicU64,
// Gauges (set explicitly; not cumulative)
iso_count: AtomicU64,
client_count: AtomicU64,
queue_count: AtomicU64,
queue_imaging: AtomicU64,
nfs_mounts_active: AtomicU64,
}
#[derive(Debug, Clone, Default)]
pub struct Metrics {
inner: Arc<Inner>,
}
impl Metrics {
#[must_use]
pub fn new() -> Self {
Self::default()
}
// ── DHCP ───────────────────────────────────────────────────────────
pub fn record_dhcp_reply(&self, arch: &str) {
let counter = match arch {
"bios" => &self.inner.dhcp_replies_legacy,
"uefi-x64" | "uefi-ia32" => &self.inner.dhcp_replies_uefi,
"uefi-arm64" => &self.inner.dhcp_replies_arm64,
_ => &self.inner.dhcp_replies_unknown,
};
counter.fetch_add(1, Ordering::Relaxed);
}
pub fn record_dhcp_decline(&self) {
self.inner.dhcp_declined.fetch_add(1, Ordering::Relaxed);
}
// ── TFTP ───────────────────────────────────────────────────────────
pub fn record_tftp_ok(&self, bytes: u64) {
self.inner.tftp_transfers_ok.fetch_add(1, Ordering::Relaxed);
self.inner.tftp_bytes.fetch_add(bytes, Ordering::Relaxed);
}
pub fn record_tftp_err(&self) {
self.inner.tftp_transfers_err.fetch_add(1, Ordering::Relaxed);
}
// ── HTTP ───────────────────────────────────────────────────────────
pub fn record_http(&self, route: HttpRoute) {
let counter = match route {
HttpRoute::BootScript => &self.inner.http_boot_script,
HttpRoute::IsoRange => &self.inner.http_iso_range,
HttpRoute::IsoInner => &self.inner.http_iso_inner,
HttpRoute::IpxeBinary => &self.inner.http_ipxe_binary,
HttpRoute::Api => &self.inner.http_api,
};
counter.fetch_add(1, Ordering::Relaxed);
}
// ── Gauges ─────────────────────────────────────────────────────────
pub fn set_iso_count(&self, n: u64) {
self.inner.iso_count.store(n, Ordering::Relaxed);
}
pub fn set_client_count(&self, n: u64) {
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_nfs_active(&self, n: u64) {
self.inner.nfs_mounts_active.store(n, Ordering::Relaxed);
}
/// Render in the Prometheus text exposition format.
/// Uptime is supplied by the caller because `Metrics` doesn't own
/// the start instant; the HTTP layer does.
#[must_use]
pub fn render(&self, version: &str, uptime_secs: u64) -> String {
let mut out = String::with_capacity(2048);
let i = &self.inner;
// Helper closures.
let write_counter = |o: &mut String, name: &str, help: &str, val: u64, lbl: &str| {
let _ = writeln!(o, "# HELP {name} {help}");
let _ = writeln!(o, "# TYPE {name} counter");
if lbl.is_empty() {
let _ = writeln!(o, "{name} {val}");
} else {
let _ = writeln!(o, "{name}{{{lbl}}} {val}");
}
};
let write_gauge = |o: &mut String, name: &str, help: &str, val: u64, lbl: &str| {
let _ = writeln!(o, "# HELP {name} {help}");
let _ = writeln!(o, "# TYPE {name} gauge");
if lbl.is_empty() {
let _ = writeln!(o, "{name} {val}");
} else {
let _ = writeln!(o, "{name}{{{lbl}}} {val}");
}
};
// 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,
"openpxe_dhcp_replies_total{{arch=\"bios\"}} {}",
i.dhcp_replies_legacy.load(Ordering::Relaxed)
);
let _ = writeln!(
out,
"openpxe_dhcp_replies_total{{arch=\"uefi\"}} {}",
i.dhcp_replies_uefi.load(Ordering::Relaxed)
);
let _ = writeln!(
out,
"openpxe_dhcp_replies_total{{arch=\"arm64\"}} {}",
i.dhcp_replies_arm64.load(Ordering::Relaxed)
);
let _ = writeln!(
out,
"openpxe_dhcp_replies_total{{arch=\"unknown\"}} {}",
i.dhcp_replies_unknown.load(Ordering::Relaxed)
);
write_counter(
&mut out,
"openpxe_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 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\"}} {}",
i.tftp_transfers_ok.load(Ordering::Relaxed)
);
let _ = writeln!(
out,
"openpxe_tftp_transfers_total{{status=\"err\"}} {}",
i.tftp_transfers_err.load(Ordering::Relaxed)
);
write_counter(
&mut out,
"openpxe_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");
for (label, counter) in [
("boot_script", &i.http_boot_script),
("iso_range", &i.http_iso_range),
("iso_inner", &i.http_iso_inner),
("ipxe_binary", &i.http_ipxe_binary),
("api", &i.http_api),
] {
let _ = writeln!(
out,
"openpxe_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, "");
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");
out
}
}
/// Stable label values for the HTTP route counter. Adding a new route
/// here without updating `record_http` will break compilation, which is
/// exactly the safety we want — Prometheus alerts on cardinality drift,
/// so accidental new label values matter.
#[derive(Debug, Clone, Copy)]
pub enum HttpRoute {
BootScript,
IsoRange,
IsoInner,
IpxeBinary,
Api,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_emits_each_metric_family_once() {
let m = Metrics::new();
m.record_dhcp_reply("uefi-x64");
m.record_dhcp_reply("bios");
m.record_tftp_ok(1024);
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"));
}
#[test]
fn cloned_metrics_share_state() {
let a = Metrics::new();
let b = a.clone();
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"));
}
}
@@ -1,14 +1,14 @@
//! Gated Deployment queue. //! Queued Deployment queue.
//! //!
//! When a client selects "Gated Deployment" at the PXE menu, iPXE POSTs to //! When a client selects "Queued Deployment" at the PXE menu, iPXE POSTs to
//! `/api/gate/join` and receives a gate position. It then enters a poll //! `/api/queue/join` and receives a gate position. It then enters a poll
//! loop hitting `/api/gate/poll/<id>`; the server holds the request open //! loop hitting `/api/queue/poll/<id>`; the server holds the request open
//! until either (a) the operator assigns an ISO from the WebUI, in which //! until either (a) the operator assigns an ISO from the WebUI, in which
//! case the poll returns an iPXE `chain` URL, or (b) the poll times out //! case the poll returns an iPXE `chain` URL, or (b) the poll times out
//! (iPXE's HTTP client has its own timeout), in which case iPXE re-POSTs. //! (iPXE's HTTP client has its own timeout), in which case iPXE re-POSTs.
//! //!
//! The WebUI shows the queue (`GET /api/gate`) and issues //! The WebUI shows the queue (`GET /api/gate`) and issues
//! `POST /api/gate/assign { iso_id, gate_ids: [...] }` to launch a single //! `POST /api/queue/assign { iso_id, entry_ids: [...] }` to launch a single
//! ISO across many gated clients at once. This is the "horse-race gate" //! ISO across many gated clients at once. This is the "horse-race gate"
//! UX the user asked for — every horse leaves the line simultaneously. //! UX the user asked for — every horse leaves the line simultaneously.
@@ -40,7 +40,7 @@ pub struct Gate {
} }
#[derive(Debug)] #[derive(Debug)]
struct GateInner { struct QueueEntryInner {
id: String, id: String,
position: u32, position: u32,
mac: String, mac: String,
@@ -54,7 +54,7 @@ struct GateInner {
notify: Arc<Notify>, notify: Arc<Notify>,
} }
impl GateInner { impl QueueEntryInner {
fn snapshot(&self) -> Gate { fn snapshot(&self) -> Gate {
Gate { Gate {
id: self.id.clone(), id: self.id.clone(),
@@ -70,11 +70,11 @@ impl GateInner {
} }
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct GateQueue { pub struct DeploymentQueue {
inner: RwLock<HashMap<String, GateInner>>, inner: RwLock<HashMap<String, QueueEntryInner>>,
} }
impl GateQueue { impl DeploymentQueue {
#[must_use] #[must_use]
pub fn new() -> Arc<Self> { pub fn new() -> Arc<Self> {
Arc::new(Self::default()) Arc::new(Self::default())
@@ -97,7 +97,7 @@ impl GateQueue {
// Race 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 next_pos = guard.values().map(|g| g.position).max().unwrap_or(0) + 1;
let id = Uuid::new_v4().to_string(); let id = Uuid::new_v4().to_string();
let inner = GateInner { let inner = QueueEntryInner {
id: id.clone(), id: id.clone(),
position: next_pos, position: next_pos,
mac: mac.to_string(), mac: mac.to_string(),
@@ -115,16 +115,16 @@ impl GateQueue {
/// Look up the `Notify` primitive for a given gate id, for long-polling. /// Look up the `Notify` primitive for a given gate id, for long-polling.
#[must_use] #[must_use]
pub fn notifier(&self, gate_id: &str) -> Option<Arc<Notify>> { pub fn notifier(&self, entry_id: &str) -> Option<Arc<Notify>> {
self.inner.read().get(gate_id).map(|g| g.notify.clone()) self.inner.read().get(entry_id).map(|g| g.notify.clone())
} }
/// Update the last-poll timestamp (keeps the gate's "live" indicator /// Update the last-poll timestamp (keeps the gate's "live" indicator
/// fresh in the UI) and return the current snapshot. Returns None if /// fresh in the UI) and return the current snapshot. Returns None if
/// the gate was released/expired between requests. /// the gate was released/expired between requests.
pub fn touch(&self, gate_id: &str) -> Option<Gate> { pub fn touch(&self, entry_id: &str) -> Option<Gate> {
let mut guard = self.inner.write(); let mut guard = self.inner.write();
let g = guard.get_mut(gate_id)?; let g = guard.get_mut(entry_id)?;
g.last_poll_at = OffsetDateTime::now_utc(); g.last_poll_at = OffsetDateTime::now_utc();
Some(g.snapshot()) Some(g.snapshot())
} }
@@ -132,10 +132,10 @@ impl GateQueue {
/// Operator assigns an ISO entry (boot_entry id) to one or more gates. /// Operator assigns an ISO entry (boot_entry id) to one or more gates.
/// Returns the number of gates that were updated. Gates not in the /// Returns the number of gates that were updated. Gates not in the
/// queue are silently skipped. /// queue are silently skipped.
pub fn assign(&self, gate_ids: &[String], target: &str) -> usize { pub fn assign(&self, entry_ids: &[String], target: &str) -> usize {
let mut guard = self.inner.write(); let mut guard = self.inner.write();
let mut updated = 0; let mut updated = 0;
for id in gate_ids { for id in entry_ids {
if let Some(g) = guard.get_mut(id) { if let Some(g) = guard.get_mut(id) {
g.assigned_target = Some(target.to_string()); g.assigned_target = Some(target.to_string());
g.notify.notify_waiters(); g.notify.notify_waiters();
@@ -147,9 +147,9 @@ impl GateQueue {
/// Remove a gate 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. /// has successfully chained onto its assignment.
pub fn release(&self, gate_id: &str) -> Option<Gate> { pub fn release(&self, entry_id: &str) -> Option<Gate> {
let mut guard = self.inner.write(); let mut guard = self.inner.write();
let g = guard.remove(gate_id)?; let g = guard.remove(entry_id)?;
g.notify.notify_waiters(); g.notify.notify_waiters();
// Renumber positions so the display stays contiguous (1..N). This // Renumber positions so the display stays contiguous (1..N). This
// is O(N) but the queue is expected to be small (dozens of hosts). // is O(N) but the queue is expected to be small (dozens of hosts).
@@ -164,7 +164,7 @@ impl GateQueue {
#[must_use] #[must_use]
pub fn list(&self) -> Vec<Gate> { pub fn list(&self) -> Vec<Gate> {
let guard = self.inner.read(); let guard = self.inner.read();
let mut v: Vec<_> = guard.values().map(GateInner::snapshot).collect(); let mut v: Vec<_> = guard.values().map(QueueEntryInner::snapshot).collect();
v.sort_by_key(|g| g.position); v.sort_by_key(|g| g.position);
v v
} }
@@ -186,7 +186,7 @@ mod tests {
#[test] #[test]
fn join_assigns_sequential_positions() { fn join_assigns_sequential_positions() {
let q = GateQueue::new(); let q = DeploymentQueue::new();
let g1 = q.join("aa:bb:cc:00:00:01", None, None); 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 g2 = q.join("aa:bb:cc:00:00:02", None, None);
let g3 = q.join("aa:bb:cc:00:00:03", None, None); let g3 = q.join("aa:bb:cc:00:00:03", None, None);
@@ -197,7 +197,7 @@ mod tests {
#[test] #[test]
fn rejoining_same_mac_is_idempotent() { fn rejoining_same_mac_is_idempotent() {
let q = GateQueue::new(); let q = DeploymentQueue::new();
let g1 = q.join("aa:bb:cc:00:00:01", None, None); let g1 = q.join("aa:bb:cc:00:00:01", None, None);
let g2 = 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); assert_eq!(g1.id, g2.id);
@@ -207,7 +207,7 @@ mod tests {
#[test] #[test]
fn assign_broadcasts_target() { fn assign_broadcasts_target() {
let q = GateQueue::new(); let q = DeploymentQueue::new();
let g1 = q.join("aa:bb:cc:00:00:01", None, None); 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 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"); let n = q.assign(&[g1.id.clone(), g2.id.clone()], "ubuntu-24-04-linux");
@@ -219,7 +219,7 @@ mod tests {
#[test] #[test]
fn release_renumbers() { fn release_renumbers() {
let q = GateQueue::new(); let q = DeploymentQueue::new();
let a = q.join("aa:00:00:00:00:01", None, None); 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 _b = q.join("aa:00:00:00:00:02", None, None);
let c = q.join("aa:00:00:00:00:03", None, None); let c = q.join("aa:00:00:00:00:03", None, None);
@@ -234,7 +234,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn assign_wakes_waiter() { async fn assign_wakes_waiter() {
let q = GateQueue::new(); let q = DeploymentQueue::new();
let g = q.join("aa:00:00:00:00:01", None, None); let g = q.join("aa:00:00:00:00:01", None, None);
let notify = q.notifier(&g.id).unwrap(); let notify = q.notifier(&g.id).unwrap();
@@ -246,7 +246,7 @@ mod tests {
q3.touch(&id) q3.touch(&id)
}); });
tokio::time::sleep(std::time::Duration::from_millis(10)).await; tokio::time::sleep(std::time::Duration::from_millis(10)).await;
q2.assign(&[g.id.clone()], "x"); q2.assign(std::slice::from_ref(&g.id), "x");
let result = fut.await.unwrap(); let result = fut.await.unwrap();
assert!(result.is_some()); assert!(result.is_some());
assert_eq!(result.unwrap().assigned_target.as_deref(), Some("x")); assert_eq!(result.unwrap().assigned_target.as_deref(), Some("x"));
+12 -8
View File
@@ -47,13 +47,13 @@ pub struct Settings {
/// `timeout_action = LocalHdd`). /// `timeout_action = LocalHdd`).
pub default_local_hdd: bool, pub default_local_hdd: bool,
/// When a client hits the Gated Deployment item, how long (seconds) to /// When a client hits the Queued Deployment item, how long (seconds) to
/// hold it at the gate before giving up and falling back to the menu. /// hold it at the gate before giving up and falling back to the menu.
/// 0 = forever. /// 0 = forever.
pub gate_wait_max_secs: u32, pub gate_wait_max_secs: u32,
/// Optional DNS server advertised on the Network tab. Purely /// Optional DNS server advertised on the Network tab. Purely
/// informational today — PXEForge does not run a DNS server, but /// informational today — OpenPXE does not run a DNS server, but
/// operators expect to be able to record what the upstream DNS is. /// operators expect to be able to record what the upstream DNS is.
/// Empty string = unset (UI shows placeholder). /// Empty string = unset (UI shows placeholder).
pub dns_server: String, pub dns_server: String,
@@ -66,16 +66,20 @@ pub enum TimeoutAction {
Stay, Stay,
/// Chain the "Boot from Local HDD" entry. /// Chain the "Boot from Local HDD" entry.
LocalHdd, LocalHdd,
/// Put the client into the gate queue, waiting for operator assignment. /// Put the client into the deployment queue, waiting for operator
/// assignment. The serde alias keeps v0.2.0 settings.json files
/// readable after the v0.3.0 rename — old `"gated_deployment"`
/// values deserialize transparently.
#[default] #[default]
GatedDeployment, #[serde(alias = "gated_deployment")]
QueuedDeployment,
} }
impl Default for Settings { impl Default for Settings {
fn default() -> Self { fn default() -> Self {
Self { Self {
boot_menu_timeout_secs: 600, boot_menu_timeout_secs: 600,
timeout_action: TimeoutAction::GatedDeployment, timeout_action: TimeoutAction::QueuedDeployment,
windows_enabled: false, windows_enabled: false,
smb_host_override: String::new(), smb_host_override: String::new(),
extra_kernel_args: String::new(), extra_kernel_args: String::new(),
@@ -103,7 +107,7 @@ impl SettingsStore {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
tracing::warn!( tracing::warn!(
target: "pxeforge::settings", target: "openpxe::settings",
"settings.json present but unreadable ({e}); falling back to defaults" "settings.json present but unreadable ({e}); falling back to defaults"
); );
Settings::default() Settings::default()
@@ -130,7 +134,7 @@ impl SettingsStore {
} }
let snap = self.snapshot(); let snap = self.snapshot();
if let Err(e) = self.persist(&snap) { if let Err(e) = self.persist(&snap) {
tracing::warn!(target: "pxeforge::settings", "failed to persist settings: {e}"); tracing::warn!(target: "openpxe::settings", "failed to persist settings: {e}");
} }
} }
@@ -157,7 +161,7 @@ mod tests {
let store = SettingsStore::load_or_default(dir.path()); let store = SettingsStore::load_or_default(dir.path());
let s = store.snapshot(); let s = store.snapshot();
assert_eq!(s.boot_menu_timeout_secs, 600); assert_eq!(s.boot_menu_timeout_secs, 600);
assert_eq!(s.timeout_action, TimeoutAction::GatedDeployment); assert_eq!(s.timeout_action, TimeoutAction::QueuedDeployment);
assert!(!s.windows_enabled); assert!(!s.windows_enabled);
} }
+3 -3
View File
@@ -1,16 +1,16 @@
[package] [package]
name = "pxeforge-dhcp-proxy" name = "openpxe-dhcp-proxy"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
description = "DHCP proxy (RFC 4578) for PXEForge — serves boot info, does not lease IPs" description = "DHCP proxy (RFC 4578) for OpenPXE — serves boot info, does not lease IPs"
[lints] [lints]
workspace = true workspace = true
[dependencies] [dependencies]
pxeforge-core.workspace = true openpxe-core.workspace = true
tokio.workspace = true tokio.workspace = true
socket2.workspace = true socket2.workspace = true
dhcproto.workspace = true dhcproto.workspace = true
+9 -2
View File
@@ -9,7 +9,7 @@
//! pass, or the HTTP URL of the boot script once iPXE has chained. //! pass, or the HTTP URL of the boot script once iPXE has chained.
use dhcproto::v4::{DhcpOption, Message, MessageType, Opcode, OptionCode}; use dhcproto::v4::{DhcpOption, Message, MessageType, Opcode, OptionCode};
use pxeforge_core::{ClientArch, FirmwareClass}; use openpxe_core::{ClientArch, FirmwareClass};
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
/// Where the reply directs the client next. /// Where the reply directs the client next.
@@ -41,7 +41,14 @@ pub struct ReplyContext<'a> {
pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective { pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
match ctx.class { match ctx.class {
FirmwareClass::IpxeUserClass => BootDirective::HttpScript { FirmwareClass::IpxeUserClass => BootDirective::HttpScript {
url: format!("{}/boot.ipxe", ctx.public_base_url.trim_end_matches('/')), // Pass the client's MAC in the query string so the HTTP
// layer can short-circuit to a per-MAC binding when one
// exists. iPXE substitutes `${mac}` literally before issuing
// the GET, so this stays static across firmwares.
url: format!(
"{}/boot.ipxe?mac=${{mac}}",
ctx.public_base_url.trim_end_matches('/')
),
}, },
FirmwareClass::HttpClient => { FirmwareClass::HttpClient => {
// UEFI HTTP boot: client wants an http:// URL in option 67 // UEFI HTTP boot: client wants an http:// URL in option 67
+19 -7
View File
@@ -4,7 +4,7 @@
use crate::reply::{build_reply, decide, BootDirective, ReplyContext}; use crate::reply::{build_reply, decide, BootDirective, ReplyContext};
use dhcproto::v4::{DhcpOption, Message, OptionCode}; use dhcproto::v4::{DhcpOption, Message, OptionCode};
use dhcproto::{Decodable, Decoder, Encodable, Encoder}; use dhcproto::{Decodable, Decoder, Encodable, Encoder};
use pxeforge_core::{ use openpxe_core::{
ClientArch, ClientEvent, ClientRegistry, FirmwareClass, ClientArch, ClientEvent, ClientRegistry, FirmwareClass,
}; };
use socket2::{Domain, Protocol, Socket, Type}; use socket2::{Domain, Protocol, Socket, Type};
@@ -19,6 +19,7 @@ pub struct DhcpProxyServer {
our_ip: Ipv4Addr, our_ip: Ipv4Addr,
public_base_url: String, public_base_url: String,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
} }
impl DhcpProxyServer { impl DhcpProxyServer {
@@ -29,15 +30,24 @@ impl DhcpProxyServer {
our_ip: Ipv4Addr, our_ip: Ipv4Addr,
public_base_url: String, public_base_url: String,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
) -> Self { ) -> Self {
Self { bind, dhcp_port, pxe_port, our_ip, public_base_url, clients } Self {
bind,
dhcp_port,
pxe_port,
our_ip,
public_base_url,
clients,
metrics,
}
} }
pub async fn run(self) -> anyhow::Result<()> { pub async fn run(self) -> anyhow::Result<()> {
let dhcp_sock = bind_udp(self.bind, self.dhcp_port, true)?; let dhcp_sock = bind_udp(self.bind, self.dhcp_port, true)?;
let pxe_sock = bind_udp(self.bind, self.pxe_port, false)?; let pxe_sock = bind_udp(self.bind, self.pxe_port, false)?;
tracing::info!( tracing::info!(
target: "pxeforge::dhcp", target: "openpxe::dhcp",
"DHCP proxy listening on {}:{} and :{}", "DHCP proxy listening on {}:{} and :{}",
self.bind, self.dhcp_port, self.pxe_port self.bind, self.dhcp_port, self.pxe_port
); );
@@ -57,12 +67,12 @@ impl DhcpProxyServer {
let (n, from) = match sock.recv_from(&mut buf).await { let (n, from) = match sock.recv_from(&mut buf).await {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
tracing::warn!(target: "pxeforge::dhcp", port=label, "recv error: {e}"); tracing::warn!(target: "openpxe::dhcp", port=label, "recv error: {e}");
continue; continue;
} }
}; };
if let Err(e) = self.handle_datagram(&sock, &buf[..n], from, label).await { if let Err(e) = self.handle_datagram(&sock, &buf[..n], from, label).await {
tracing::warn!(target: "pxeforge::dhcp", port=label, "handle error: {e}"); tracing::warn!(target: "openpxe::dhcp", port=label, "handle error: {e}");
} }
} }
} }
@@ -116,12 +126,14 @@ impl DhcpProxyServer {
}; };
let directive = decide(&ctx); let directive = decide(&ctx);
if matches!(directive, BootDirective::Ignore) { if matches!(directive, BootDirective::Ignore) {
self.metrics.record_dhcp_decline();
tracing::debug!( tracing::debug!(
target: "pxeforge::dhcp", target: "openpxe::dhcp",
mac=%mac, arch=?arch, "ignoring — no bootfile for arch" mac=%mac, arch=?arch, "ignoring — no bootfile for arch"
); );
return Ok(()); 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); let mut out = Vec::with_capacity(512);
@@ -130,7 +142,7 @@ impl DhcpProxyServer {
let dest = reply_destination(&request, from); let dest = reply_destination(&request, from);
sock.send_to(&out, dest).await?; sock.send_to(&out, dest).await?;
tracing::info!( tracing::info!(
target: "pxeforge::dhcp", target: "openpxe::dhcp",
mac=%mac, arch=arch.as_str(), class=?class, dest=%dest, directive=?directive, mac=%mac, arch=arch.as_str(), class=?class, dest=%dest, directive=?directive,
"PXE reply sent" "PXE reply sent"
); );
+5 -5
View File
@@ -1,5 +1,5 @@
[package] [package]
name = "pxeforge-http-api" name = "openpxe-http-api"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
@@ -10,10 +10,10 @@ description = "HTTP server: ISO uploads, iPXE script generation, ISO streaming"
workspace = true workspace = true
[dependencies] [dependencies]
pxeforge-core.workspace = true openpxe-core.workspace = true
pxeforge-iso-store.workspace = true openpxe-iso-store.workspace = true
pxeforge-ipxe-assets.workspace = true openpxe-ipxe-assets.workspace = true
pxeforge-webui.workspace = true openpxe-webui.workspace = true
tokio.workspace = true tokio.workspace = true
tokio-util.workspace = true tokio-util.workspace = true
tokio-stream.workspace = true tokio-stream.workspace = true
+206 -51
View File
@@ -4,7 +4,7 @@
//! //!
//! | Group | Purpose | //! | Group | Purpose |
//! |------------------|-------------------------------------------------------| //! |------------------|-------------------------------------------------------|
//! | `/` | Web UI (served from `pxeforge-webui`) | //! | `/` | Web UI (served from `openpxe-webui`) |
//! | `/boot.ipxe` | Top-level iPXE menu | //! | `/boot.ipxe` | Top-level iPXE menu |
//! | `/boot/_*.ipxe` | Submenu scripts (hierarchy: linux, windows, tools, …) | //! | `/boot/_*.ipxe` | Submenu scripts (hierarchy: linux, windows, tools, …) |
//! | `/boot/<id>.ipxe`| Per-entry boot script | //! | `/boot/<id>.ipxe`| Per-entry boot script |
@@ -14,7 +14,7 @@
//! | `/api/*` | JSON/HTML API for the web UI | //! | `/api/*` | JSON/HTML API for the web UI |
use crate::ipxe_script::{ use crate::ipxe_script::{
render_entry, render_family_menu, render_gate_entry, render_local_hdd, render_entry, render_family_menu, render_queue_entry, render_local_hdd,
render_menu, render_nic_info, render_shell, render_tools_menu, render_util, render_menu, render_nic_info, render_shell, render_tools_menu, render_util,
}; };
use crate::iso_fs; use crate::iso_fs;
@@ -29,9 +29,9 @@ use axum::{
routing::{delete, get, post}, routing::{delete, get, post},
Json, Router, Json, Router,
}; };
use pxeforge_core::{ClientEvent, Settings}; use openpxe_core::{ClientEvent, Settings};
use pxeforge_ipxe_assets::asset_bytes; use openpxe_ipxe_assets::asset_bytes;
use pxeforge_iso_store::{IsoMeta, NfsAddRequest}; use openpxe_iso_store::{IsoMeta, NfsAddRequest};
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use std::time::Duration; use std::time::Duration;
@@ -45,6 +45,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/assets/app.js", get(ui_js)) .route("/assets/app.js", get(ui_js))
.route("/assets/app.css", get(ui_css)) .route("/assets/app.css", get(ui_css))
.route("/assets/logo.svg", get(ui_logo)) .route("/assets/logo.svg", get(ui_logo))
.route("/assets/loader.svg", get(ui_loader))
// iPXE script endpoints. // iPXE script endpoints.
.route("/boot.ipxe", get(boot_top_menu)) .route("/boot.ipxe", get(boot_top_menu))
.route("/boot/:filename", get(boot_sub)) .route("/boot/:filename", get(boot_sub))
@@ -63,11 +64,11 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/clients", get(api_list_clients)) .route("/api/clients", get(api_list_clients))
.route("/api/status", get(api_status)) .route("/api/status", get(api_status))
.route("/api/settings", get(api_get_settings).put(api_put_settings)) .route("/api/settings", get(api_get_settings).put(api_put_settings))
.route("/api/gate", get(api_list_gates)) .route("/api/queue", get(api_list_queue))
.route("/api/gate/join", get(api_gate_join)) .route("/api/queue/join", get(api_queue_join))
.route("/api/gate/poll/:gate_id", get(api_gate_poll)) .route("/api/queue/poll/:entry_id", get(api_queue_poll))
.route("/api/gate/assign", post(api_gate_assign)) .route("/api/queue/assign", post(api_queue_assign))
.route("/api/gate/:gate_id", delete(api_gate_release)) .route("/api/queue/:entry_id", delete(api_queue_release))
// Phase 4: NFS share manager. // Phase 4: NFS share manager.
.route("/api/nfs", get(api_nfs_list).post(api_nfs_add)) .route("/api/nfs", get(api_nfs_list).post(api_nfs_add))
.route("/api/nfs/:id", delete(api_nfs_remove)) .route("/api/nfs/:id", delete(api_nfs_remove))
@@ -80,6 +81,14 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/log/clear", post(log_stream::clear)) .route("/api/log/clear", post(log_stream::clear))
// Phase 4: operator terminal commands (whitelisted). // Phase 4: operator terminal commands (whitelisted).
.route("/api/terminal", post(terminal::run_command)) .route("/api/terminal", post(terminal::run_command))
// Phase 5: per-MAC host bindings (Tinkerbell-style). Operator
// pins a MAC to a boot entry; /boot.ipxe?mac=... chains directly.
.route("/api/hosts", get(api_hosts_list).post(api_hosts_upsert))
.route("/api/hosts/:mac", delete(api_hosts_remove))
// Phase 5: Prometheus scrape endpoint. Plain text exposition
// format. No auth — the metrics surface is intentionally
// boring (counts, no payloads).
.route("/metrics", get(api_metrics))
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
// 16 GiB upload cap — ISOs are big; chunks stream so this isn't memory use. // 16 GiB upload cap — ISOs are big; chunks stream so this isn't memory use.
.layer(DefaultBodyLimit::max(16 * 1024 * 1024 * 1024)) .layer(DefaultBodyLimit::max(16 * 1024 * 1024 * 1024))
@@ -89,7 +98,7 @@ pub fn build_router(state: AppState) -> Router {
// ─── UI ──────────────────────────────────────────────────────────────────── // ─── UI ────────────────────────────────────────────────────────────────────
async fn index(State(state): State<AppState>) -> Response { async fn index(State(state): State<AppState>) -> Response {
let html = pxeforge_webui::index_html(&state.public_base_url); let html = openpxe_webui::index_html(&state.public_base_url);
([(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"))], html) ([(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"))], html)
.into_response() .into_response()
} }
@@ -97,21 +106,28 @@ async fn index(State(state): State<AppState>) -> Response {
async fn ui_js() -> Response { async fn ui_js() -> Response {
( (
[(header::CONTENT_TYPE, HeaderValue::from_static("application/javascript"))], [(header::CONTENT_TYPE, HeaderValue::from_static("application/javascript"))],
pxeforge_webui::app_js(), openpxe_webui::app_js(),
).into_response() ).into_response()
} }
async fn ui_css() -> Response { async fn ui_css() -> Response {
( (
[(header::CONTENT_TYPE, HeaderValue::from_static("text/css"))], [(header::CONTENT_TYPE, HeaderValue::from_static("text/css"))],
pxeforge_webui::app_css(), openpxe_webui::app_css(),
).into_response() ).into_response()
} }
async fn ui_logo() -> Response { async fn ui_logo() -> Response {
( (
[(header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml"))], [(header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml"))],
pxeforge_webui::logo_svg(), openpxe_webui::logo_svg(),
).into_response()
}
async fn ui_loader() -> Response {
(
[(header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml"))],
openpxe_webui::loader_svg(),
).into_response() ).into_response()
} }
@@ -122,10 +138,51 @@ fn text_plain(body: String) -> Response {
.into_response() .into_response()
} }
async fn boot_top_menu(State(state): State<AppState>) -> Response { /// Top-level boot script. Honors per-MAC host bindings: if the
/// requesting client carries a `?mac=...` query param (iPXE's `${mac}`
/// substitution) and that MAC has a binding, we short-circuit straight
/// to the bound target instead of rendering the menu.
async fn boot_top_menu(
State(state): State<AppState>,
Query(p): Query<BootMenuParams>,
) -> Response {
state.metrics.record_http(openpxe_core::HttpRoute::BootScript);
let isos = state.iso_store.list(); let isos = state.iso_store.list();
let settings = state.settings.snapshot(); let settings = state.settings.snapshot();
text_plain(render_menu(&isos, &settings, &state.public_base_url)) let base = &state.public_base_url;
// Per-MAC override: if the client identified itself and we have a
// binding, chain directly. The chain target falls back to the menu
// on failure so a stale / misconfigured binding can't lock a client
// out — it just shows the menu.
if let Some(mac) = p.mac.as_deref() {
if let Some(binding) = state.hosts.lookup(mac) {
tracing::info!(
target: "openpxe::http",
mac = %binding.mac, target = %binding.target,
"host binding applied"
);
let target = binding.target;
// Reserved menu shortcuts are emitted as `_xxx`; per-entry
// boot scripts are at `/boot/<id>.ipxe`. Both share the same
// `/boot/<name>` route, so the URL is identical.
return text_plain(format!(
"#!ipxe\n\
echo OpenPXE: per-MAC binding -> {target}\n\
chain {base}/boot/{target}.ipxe || chain {base}/boot.ipxe\n"
));
}
}
text_plain(render_menu(&isos, &settings, base))
}
#[derive(Debug, Deserialize)]
struct BootMenuParams {
/// Client MAC, supplied by iPXE via `${mac}` variable in
/// `chain ${prefix}/boot.ipxe?mac=${mac}`. Optional — if absent we
/// fall back to the menu unconditionally.
mac: Option<String>,
} }
async fn boot_sub( async fn boot_sub(
@@ -146,7 +203,7 @@ async fn boot_sub(
"_util" => render_util(base), "_util" => render_util(base),
"_shell" => render_shell(base), "_shell" => render_shell(base),
"_nic" => render_nic_info(base), "_nic" => render_nic_info(base),
"_gate" => render_gate_entry(base), "_queue" => render_queue_entry(base),
other => { other => {
for iso in &isos { for iso in &isos {
for entry in &iso.boot_entries { for entry in &iso.boot_entries {
@@ -204,7 +261,7 @@ async fn iso_file(
return (StatusCode::NOT_FOUND, "no such iso").into_response(); return (StatusCode::NOT_FOUND, "no such iso").into_response();
}; };
let p = iso_path.clone(); let p = iso_path.clone();
let in_path = format!("/{}", path); let in_path = format!("/{path}");
let loc = tokio::task::spawn_blocking(move || iso_fs::lookup(&p, &in_path)) let loc = tokio::task::spawn_blocking(move || iso_fs::lookup(&p, &in_path))
.await.ok().flatten(); .await.ok().flatten();
let Some(loc) = loc else { let Some(loc) = loc else {
@@ -334,7 +391,7 @@ async fn readyz(State(state): State<AppState>) -> Response {
// 1. At least one iPXE binary must be bundled (without one, TFTP 404s // 1. At least one iPXE binary must be bundled (without one, TFTP 404s
// and no client boots). // and no client boots).
// 2. The ISO directory must exist and be readable. // 2. The ISO directory must exist and be readable.
let assets = pxeforge_ipxe_assets::list_assets(); let assets = openpxe_ipxe_assets::list_assets();
let mut problems: Vec<&str> = Vec::new(); let mut problems: Vec<&str> = Vec::new();
if assets.is_empty() { if assets.is_empty() {
problems.push("no iPXE binaries bundled (run scripts/fetch-ipxe.sh before building)"); problems.push("no iPXE binaries bundled (run scripts/fetch-ipxe.sh before building)");
@@ -362,26 +419,36 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
let nfs = state.nfs.list(); let nfs = state.nfs.list();
let nfs_active = nfs.iter().filter(|m| m.mounted).count(); let nfs_active = nfs.iter().filter(|m| m.mounted).count();
let isos = state.iso_store.list(); let isos = state.iso_store.list();
let gates = state.gates.list(); let clients = state.clients.list();
let gates = state.queue.list();
// Phase 4: dashboard tracks "imaging" as gates with an assignment // Phase 4: dashboard tracks "imaging" as gates with an assignment
// already issued — they're the ones actively chaining a boot script. // already issued — they're the ones actively chaining a boot script.
let imaging = gates.iter().filter(|g| g.assigned_target.is_some()).count(); let imaging = gates.iter().filter(|g| g.assigned_target.is_some()).count();
let waiting = gates.len() - imaging; let waiting = gates.len() - imaging;
// Side-effect: push gauge values out to the Prometheus surface.
// Doing it here (in the most-frequently-polled endpoint) keeps the
// gauges fresh without a dedicated scrape-time hook.
state.metrics.set_iso_count(isos.len() as u64);
state.metrics.set_client_count(clients.len() as u64);
state.metrics.set_queue_counts(gates.len() as u64, imaging as u64);
state.metrics.set_nfs_active(nfs_active as u64);
state.metrics.record_http(openpxe_core::HttpRoute::Api);
let now = time::OffsetDateTime::now_utc(); let now = time::OffsetDateTime::now_utc();
let uptime_secs = (now - state.started_at).whole_seconds().max(0); let uptime_secs = (now - state.started_at).whole_seconds().max(0);
Json(json!({ Json(json!({
"version": env!("CARGO_PKG_VERSION"), "version": env!("CARGO_PKG_VERSION"),
"public_base_url": state.public_base_url, "public_base_url": state.public_base_url,
"iso_count": isos.len(), "iso_count": isos.len(),
"client_count": state.clients.list().len(), "client_count": clients.len(),
"gate_count": gates.len(), "queue_count": gates.len(),
"imaging_count": imaging, "imaging_count": imaging,
"waiting_count": waiting, "waiting_count": waiting,
"ipxe_assets": pxeforge_ipxe_assets::list_assets(), "ipxe_assets": openpxe_ipxe_assets::list_assets(),
"settings": state.settings.snapshot(), "settings": state.settings.snapshot(),
"smb": smb, "smb": smb,
"nfs_count": nfs.len(), "nfs_count": nfs.len(),
"nfs_active": nfs_active, "nfs_active": nfs_active,
"host_bindings": state.hosts.len(),
"uptime_secs": uptime_secs, "uptime_secs": uptime_secs,
"started_at": state.started_at, "started_at": state.started_at,
"nic_name": state.nic_name, "nic_name": state.nic_name,
@@ -401,7 +468,7 @@ async fn api_put_settings(
// Guardrail: Windows boot requires the wimboot shim to be bundled. // Guardrail: Windows boot requires the wimboot shim to be bundled.
// Without it, clients chain a non-existent /ipxe/wimboot and stall. // Without it, clients chain a non-existent /ipxe/wimboot and stall.
if new.windows_enabled { if new.windows_enabled {
let assets = pxeforge_ipxe_assets::list_assets(); let assets = openpxe_ipxe_assets::list_assets();
if !assets.iter().any(|n| n == "wimboot") { if !assets.iter().any(|n| n == "wimboot") {
return ( return (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
@@ -430,12 +497,12 @@ async fn api_put_settings(
StatusCode::NO_CONTENT.into_response() StatusCode::NO_CONTENT.into_response()
} }
// ─── Gated Deployment API ───────────────────────────────────────────────── // ─── Queued Deployment API ─────────────────────────────────────────────────
async fn api_list_gates(State(state): State<AppState>) -> Json<serde_json::Value> { async fn api_list_queue(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!({ Json(json!({
"gates": state.gates.list(), "entries": state.queue.list(),
"count": state.gates.list().len(), "count": state.queue.list().len(),
})) }))
} }
@@ -447,9 +514,9 @@ struct GateJoinParams {
} }
/// Called by iPXE via `chain --replace`. We respond with a tiny iPXE /// Called by iPXE via `chain --replace`. We respond with a tiny iPXE
/// script that hard-loops on `/api/gate/poll/<id>`. iPXE keeps fetching /// script that hard-loops on `/api/queue/poll/<id>`. iPXE keeps fetching
/// until poll returns an actual boot script. /// until poll returns an actual boot script.
async fn api_gate_join( async fn api_queue_join(
State(state): State<AppState>, State(state): State<AppState>,
Query(p): Query<GateJoinParams>, Query(p): Query<GateJoinParams>,
headers: HeaderMap, headers: HeaderMap,
@@ -461,10 +528,10 @@ async fn api_gate_join(
.and_then(|s| s.split(',').next()) .and_then(|s| s.split(',').next())
.and_then(|s| s.trim().parse().ok()); .and_then(|s| s.trim().parse().ok());
let gate = state.gates.join(&mac, ip, None); let gate = state.queue.join(&mac, ip, None);
state.clients.record( state.clients.record(
&mac, ip, None, &mac, ip, None,
ClientEvent::HttpScriptFetch { target: "gate-join".into() }, ClientEvent::HttpScriptFetch { target: "queue-join".into() },
); );
let base = &state.public_base_url; let base = &state.public_base_url;
@@ -473,11 +540,11 @@ async fn api_gate_join(
"#!ipxe\n\ "#!ipxe\n\
echo\n\ echo\n\
echo ==========================================\n\ echo ==========================================\n\
echo Gated Deployment - Gate Position {}\n\ echo Queued Deployment - Gate Position {}\n\
echo Waiting for operator to assign an image\n\ echo Waiting for operator to assign an image\n\
echo (Ctrl-B returns to the iPXE shell)\n\ echo (Ctrl-B returns to the iPXE shell)\n\
echo ==========================================\n\ echo ==========================================\n\
chain {base}/api/gate/poll/{}\n", chain {base}/api/queue/poll/{}\n",
gate.position, gate.id gate.position, gate.id
); );
text_plain(script) text_plain(script)
@@ -486,11 +553,11 @@ async fn api_gate_join(
/// Long-poll endpoint. Waits up to 25s for an assignment; if none, returns /// Long-poll endpoint. Waits up to 25s for an assignment; if none, returns
/// a script that loops back to itself. 25s keeps us well inside typical /// a script that loops back to itself. 25s keeps us well inside typical
/// HTTP idle timeouts for iPXE and intermediaries. /// HTTP idle timeouts for iPXE and intermediaries.
async fn api_gate_poll( async fn api_queue_poll(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(gate_id): AxumPath<String>, AxumPath(entry_id): AxumPath<String>,
) -> Response { ) -> Response {
let Some(notify) = state.gates.notifier(&gate_id) else { let Some(notify) = state.queue.notifier(&entry_id) else {
// Gate was released; send client back to the main menu. // Gate was released; send client back to the main menu.
let base = &state.public_base_url; let base = &state.public_base_url;
return text_plain(format!("#!ipxe\nchain {base}/boot.ipxe\n")); return text_plain(format!("#!ipxe\nchain {base}/boot.ipxe\n"));
@@ -499,7 +566,7 @@ async fn api_gate_poll(
// Wait for an assignment or timeout. // Wait for an assignment or timeout.
let _ = tokio::time::timeout(Duration::from_secs(25), notify.notified()).await; let _ = tokio::time::timeout(Duration::from_secs(25), notify.notified()).await;
let snap = state.gates.touch(&gate_id); let snap = state.queue.touch(&entry_id);
let base = &state.public_base_url; let base = &state.public_base_url;
match snap { match snap {
// Bind `target` directly so we can't observe an Option::None between // Bind `target` directly so we can't observe an Option::None between
@@ -513,14 +580,14 @@ async fn api_gate_poll(
Some(g) if g.assigned_target.is_some() => { Some(g) if g.assigned_target.is_some() => {
let target = g.assigned_target.clone().unwrap_or_default(); let target = g.assigned_target.clone().unwrap_or_default();
tracing::info!( tracing::info!(
target: "pxeforge::gate", target: "openpxe::queue",
gate_id=%gate_id, mac=%g.mac, target=%target, entry_id=%entry_id, mac=%g.mac, target=%target,
"gate assignment delivered" "gate assignment delivered"
); );
text_plain(format!( text_plain(format!(
"#!ipxe\n\ "#!ipxe\n\
echo Gate assignment received: {target}\n\ echo Gate assignment received: {target}\n\
chain {base}/boot/{target}.ipxe || chain {base}/api/gate/poll/{gate_id}\n" chain {base}/boot/{target}.ipxe || chain {base}/api/queue/poll/{entry_id}\n"
)) ))
} }
Some(g) => { Some(g) => {
@@ -529,7 +596,7 @@ async fn api_gate_poll(
text_plain(format!( text_plain(format!(
"#!ipxe\n\ "#!ipxe\n\
echo Gate Position {} - still waiting\n\ echo Gate Position {} - still waiting\n\
chain {base}/api/gate/poll/{gate_id}\n", chain {base}/api/queue/poll/{entry_id}\n",
g.position g.position
)) ))
} }
@@ -543,17 +610,17 @@ struct GateAssignBody {
/// `/boot/<id>.ipxe`. /// `/boot/<id>.ipxe`.
target: String, target: String,
/// Gate ids to assign. Empty = assign to all currently queued gates. /// Gate ids to assign. Empty = assign to all currently queued gates.
gate_ids: Vec<String>, entry_ids: Vec<String>,
} }
async fn api_gate_assign( async fn api_queue_assign(
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<GateAssignBody>, Json(body): Json<GateAssignBody>,
) -> Json<serde_json::Value> { ) -> Json<serde_json::Value> {
let ids = if body.gate_ids.is_empty() { let ids = if body.entry_ids.is_empty() {
state.gates.list().into_iter().map(|g| g.id).collect::<Vec<_>>() state.queue.list().into_iter().map(|g| g.id).collect::<Vec<_>>()
} else { } else {
body.gate_ids body.entry_ids
}; };
// Guard: target must exist as a BootEntry id. // Guard: target must exist as a BootEntry id.
let found = state.iso_store.list().into_iter().any(|i| { let found = state.iso_store.list().into_iter().any(|i| {
@@ -562,15 +629,15 @@ async fn api_gate_assign(
if !found { if !found {
return Json(json!({ "ok": false, "error": "unknown target" })); return Json(json!({ "ok": false, "error": "unknown target" }));
} }
let n = state.gates.assign(&ids, &body.target); let n = state.queue.assign(&ids, &body.target);
Json(json!({ "ok": true, "assigned": n, "target": body.target })) Json(json!({ "ok": true, "assigned": n, "target": body.target }))
} }
async fn api_gate_release( async fn api_queue_release(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(gate_id): AxumPath<String>, AxumPath(entry_id): AxumPath<String>,
) -> StatusCode { ) -> StatusCode {
match state.gates.release(&gate_id) { match state.queue.release(&entry_id) {
Some(_) => StatusCode::NO_CONTENT, Some(_) => StatusCode::NO_CONTENT,
None => StatusCode::NOT_FOUND, None => StatusCode::NOT_FOUND,
} }
@@ -650,6 +717,94 @@ async fn api_network_put(
StatusCode::NO_CONTENT StatusCode::NO_CONTENT
} }
// ─── Per-MAC host bindings ────────────────────────────────────────────────
async fn api_hosts_list(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!({ "hosts": state.hosts.list() }))
}
#[derive(Debug, Deserialize)]
struct HostsUpsertBody {
mac: String,
target: String,
#[serde(default)]
label: String,
}
async fn api_hosts_upsert(
State(state): State<AppState>,
Json(body): Json<HostsUpsertBody>,
) -> Response {
let mac = body.mac.trim();
if mac.is_empty() {
return (StatusCode::BAD_REQUEST, "mac is required").into_response();
}
// Sanity-check the target if the operator supplied a real boot
// entry id (anything starting with `_` is a reserved menu shortcut
// and exists by definition).
let target = body.target.trim();
if !target.starts_with('_')
&& !state
.iso_store
.list()
.into_iter()
.any(|i| i.boot_entries.iter().any(|e| e.id == target))
{
return (
StatusCode::BAD_REQUEST,
format!("unknown boot entry: {target}"),
)
.into_response();
}
let binding = state.hosts.upsert(mac, target, body.label.trim());
(StatusCode::CREATED, Json(binding)).into_response()
}
async fn api_hosts_remove(
State(state): State<AppState>,
AxumPath(mac): AxumPath<String>,
) -> StatusCode {
if state.hosts.remove(&mac) {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
// ─── Prometheus metrics ───────────────────────────────────────────────────
async fn api_metrics(State(state): State<AppState>) -> Response {
// Refresh gauges from live state before rendering — keeps the
// scrape "honest" without making /api/status the only path that
// updates them.
state
.metrics
.set_iso_count(state.iso_store.list().len() as u64);
state
.metrics
.set_client_count(state.clients.list().len() as u64);
let gates = state.queue.list();
let imaging = gates.iter().filter(|g| g.assigned_target.is_some()).count();
state
.metrics
.set_queue_counts(gates.len() as u64, imaging as u64);
state
.metrics
.set_nfs_active(state.nfs.list().iter().filter(|m| m.mounted).count() as u64);
let now = time::OffsetDateTime::now_utc();
let uptime = (now - state.started_at).whole_seconds().max(0) as u64;
let body = state.metrics.render(env!("CARGO_PKG_VERSION"), uptime);
(
[(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"),
)],
body,
)
.into_response()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+29 -29
View File
@@ -8,12 +8,12 @@
//! > Boot from Local HDD //! > Boot from Local HDD
//! Installers //! Installers
//! > Linux Installers -> submenu of Linux ISOs //! > Linux Installers -> submenu of Linux ISOs
//! > Windows Installers -> submenu of Windows ISOs (gated by Settings::windows_enabled) //! > Windows Installers -> submenu of Windows ISOs (controlled by Settings::windows_enabled)
//! Tools //! Tools
//! > Utilities -> memtest, etc. (embedded assets only) //! > Utilities -> memtest, etc. (embedded assets only)
//! > PXEForge Shell -> drop to iPXE shell with branded prompt //! > OpenPXE Shell -> drop to iPXE shell with branded prompt
//! > Network Card Info -> ifstat / config / route dump //! > Network Card Info -> ifstat / config / route dump
//! Gated Deployment -> join the gate queue //! Queued Deployment -> join the deployment queue
//! ``` //! ```
//! //!
//! ## iPXE is entirely backend — users do not see or write iPXE //! ## iPXE is entirely backend — users do not see or write iPXE
@@ -22,12 +22,12 @@
//! those knobs into iPXE primitives (chain, menu, item, choose, etc.). //! those knobs into iPXE primitives (chain, menu, item, choose, etc.).
//! There is intentionally no UI path to upload a custom `.ipxe` script. //! There is intentionally no UI path to upload a custom `.ipxe` script.
use pxeforge_core::{Settings, TimeoutAction}; use openpxe_core::{Settings, TimeoutAction};
use pxeforge_iso_store::{BootEntry, BootKind, IsoMeta}; use openpxe_iso_store::{BootEntry, BootKind, IsoMeta};
use pxeforge_iso_store::introspect::DistroFamily; use openpxe_iso_store::introspect::DistroFamily;
use std::fmt::Write as _; use std::fmt::Write as _;
/// Top-level PXEForge boot menu. Serialized identically for BIOS and UEFI /// Top-level OpenPXE boot menu. Serialized identically for BIOS and UEFI
/// clients because iPXE normalises the menu primitives across firmwares. /// clients because iPXE normalises the menu primitives across firmwares.
#[must_use] #[must_use]
pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String { pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String {
@@ -35,20 +35,20 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
let timeout_ms = settings.boot_menu_timeout_secs.saturating_mul(1000); let timeout_ms = settings.boot_menu_timeout_secs.saturating_mul(1000);
let default_item = match settings.timeout_action { let default_item = match settings.timeout_action {
TimeoutAction::LocalHdd => "local", TimeoutAction::QueuedDeployment => "queue",
TimeoutAction::GatedDeployment => "gate",
// Stay -> iPXE's `--timeout 0` is "no timeout". Pick any default // Stay -> iPXE's `--timeout 0` is "no timeout". Pick any default
// label; the client waits for keypress. // label; the client waits for keypress. We use the same label as
TimeoutAction::Stay => "local", // LocalHdd to keep the menu's pre-highlight stable.
TimeoutAction::LocalHdd | TimeoutAction::Stay => "local",
}; };
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "# PXEForge top-level menu - auto-generated, do not edit"); let _ = writeln!(s, "# OpenPXE top-level menu - auto-generated, do not edit");
let _ = writeln!(s, "set base-url {base}"); let _ = writeln!(s, "set base-url {base}");
let _ = writeln!(s, "set esc:hex 1b"); let _ = writeln!(s, "set esc:hex 1b");
let _ = writeln!(s, "set cls ${{esc:string}}[2J"); let _ = writeln!(s, "set cls ${{esc:string}}[2J");
let _ = writeln!(s, ":menu"); let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu PXEForge - network boot menu"); let _ = writeln!(s, "menu OpenPXE - network boot menu");
let _ = writeln!(s, "item --gap -- ------------------------- Default -------------------------"); let _ = writeln!(s, "item --gap -- ------------------------- Default -------------------------");
let _ = writeln!(s, "item local Boot from Local HDD"); let _ = writeln!(s, "item local Boot from Local HDD");
let _ = writeln!(s, "item --gap -- ----------------------- Installers -----------------------"); let _ = writeln!(s, "item --gap -- ----------------------- Installers -----------------------");
@@ -66,8 +66,8 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
} }
let _ = writeln!(s, "item --gap -- -------------------------- Tools --------------------------"); let _ = writeln!(s, "item --gap -- -------------------------- Tools --------------------------");
let _ = writeln!(s, "item tools Tools >"); let _ = writeln!(s, "item tools Tools >");
let _ = writeln!(s, "item --gap -- ---------------------- Gated Deployment ---------------------"); let _ = writeln!(s, "item --gap -- ---------------------- Queued Deployment ---------------------");
let _ = writeln!(s, "item gate Gated Deployment (join queue)"); let _ = writeln!(s, "item queue Queued Deployment (join queue)");
let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key x exit Exit iPXE"); let _ = writeln!(s, "item --key x exit Exit iPXE");
@@ -84,7 +84,7 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
let _ = writeln!(s, "iseq ${{target}} linux && chain {base}/boot/_linux_menu.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}} 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}} 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}} queue && chain {base}/boot/_queue.ipxe || goto menu");
let _ = writeln!(s, "iseq ${{target}} exit && exit || goto menu"); let _ = writeln!(s, "iseq ${{target}} exit && exit || goto menu");
let _ = writeln!(s, "goto menu"); let _ = writeln!(s, "goto menu");
s s
@@ -101,7 +101,7 @@ pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) ->
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "set base-url {base}"); let _ = writeln!(s, "set base-url {base}");
let _ = writeln!(s, ":menu"); let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu PXEForge - {title}"); let _ = writeln!(s, "menu OpenPXE - {title}");
let filter: fn(DistroFamily) -> bool = let filter: fn(DistroFamily) -> bool =
if is_windows { is_windows_family } else { is_linux_family }; if is_windows { is_windows_family } else { is_linux_family };
let mut count = 0; let mut count = 0;
@@ -135,7 +135,7 @@ pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) ->
/// operator expectations from the original tool). /// operator expectations from the original tool).
fn fmt_size_mib(bytes: u64) -> String { fn fmt_size_mib(bytes: u64) -> String {
let mib = bytes / (1024 * 1024); let mib = bytes / (1024 * 1024);
format!("{} MB", mib) format!("{mib} MB")
} }
/// Assign `--key N <id>` hotkeys 1..9, then nothing for positions >=9. /// Assign `--key N <id>` hotkeys 1..9, then nothing for positions >=9.
@@ -156,9 +156,9 @@ pub fn render_tools_menu(base_url: &str) -> String {
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "set base-url {base}"); let _ = writeln!(s, "set base-url {base}");
let _ = writeln!(s, ":menu"); let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu PXEForge - Tools"); let _ = writeln!(s, "menu OpenPXE - Tools");
let _ = writeln!(s, "item --key u util Utilities (memtest, ...)"); let _ = writeln!(s, "item --key u util Utilities (memtest, ...)");
let _ = writeln!(s, "item --key s shell PXEForge Shell"); let _ = writeln!(s, "item --key s shell OpenPXE Shell");
let _ = writeln!(s, "item --key n nic Network Card Info"); let _ = writeln!(s, "item --key n nic Network Card Info");
let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key r reboot Reboot Computer"); let _ = writeln!(s, "item --key r reboot Reboot Computer");
@@ -202,7 +202,7 @@ pub fn render_util(base_url: &str) -> String {
let mut s = String::new(); let mut s = String::new();
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, ":menu"); let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu PXEForge - Utilities"); let _ = writeln!(s, "menu OpenPXE - Utilities");
let _ = writeln!(s, "item memtest MemTest86+ (RAM diagnostic)"); let _ = writeln!(s, "item memtest MemTest86+ (RAM diagnostic)");
let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item back < Back"); let _ = writeln!(s, "item back < Back");
@@ -213,14 +213,14 @@ pub fn render_util(base_url: &str) -> String {
s s
} }
/// "PXEForge Shell" — iPXE shell, branded. /// "OpenPXE Shell" — iPXE shell, branded.
#[must_use] #[must_use]
pub fn render_shell(base_url: &str) -> String { pub fn render_shell(base_url: &str) -> String {
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
let mut s = String::new(); let mut s = String::new();
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "echo =========================================="); let _ = writeln!(s, "echo ==========================================");
let _ = writeln!(s, "echo PXEForge Shell"); let _ = writeln!(s, "echo OpenPXE Shell");
let _ = writeln!(s, "echo 'exit' returns to the main menu"); let _ = writeln!(s, "echo 'exit' returns to the main menu");
let _ = writeln!(s, "echo =========================================="); let _ = writeln!(s, "echo ==========================================");
let _ = writeln!(s, "shell"); let _ = writeln!(s, "shell");
@@ -247,20 +247,20 @@ pub fn render_nic_info(base_url: &str) -> String {
s s
} }
/// Gated Deployment entry point. Joins the queue, then enters a long-poll /// Queued Deployment entry point. Joins the queue, then enters a long-poll
/// loop (iPXE repeats the chain on 3xx redirects / HTTP errors until a /// loop (iPXE repeats the chain on 3xx redirects / HTTP errors until a
/// real script comes back). /// real script comes back).
#[must_use] #[must_use]
pub fn render_gate_entry(base_url: &str) -> String { pub fn render_queue_entry(base_url: &str) -> String {
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
let mut s = String::new(); let mut s = String::new();
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "# Gated Deployment - join the queue and wait for operator"); let _ = writeln!(s, "# Queued Deployment - join the queue and wait for operator");
let _ = writeln!(s, "echo Joining gate queue..."); let _ = writeln!(s, "echo Joining deployment queue...");
// imgfetch writes the body to a file in iPXE's transient FS; we read // imgfetch writes the body to a file in iPXE's transient FS; we read
// the gate id out of the Location-style header by asking the server // the queue entry id out of the Location-style header by asking the server
// to put it in the response body as a single token. // to put it in the response body as a single token.
let _ = writeln!(s, "chain --replace {base}/api/gate/join?mac=${{mac}}"); let _ = writeln!(s, "chain --replace {base}/api/queue/join?mac=${{mac}}");
s s
} }
+3 -3
View File
@@ -1,13 +1,13 @@
//! HTTP server — single axum app that serves: //! HTTP server — single axum app that serves:
//! - `/` the web UI (static assets from `pxeforge-webui`) //! - `/` the web UI (static assets from `openpxe-webui`)
//! - `/api/*` JSON API for the web UI //! - `/api/*` JSON API for the web UI
//! - `/boot.ipxe` the generated top-level iPXE boot menu //! - `/boot.ipxe` the generated top-level iPXE boot menu
//! - `/boot/<entry>.ipxe` per-entry iPXE scripts (one per boot target) //! - `/boot/<entry>.ipxe` per-entry iPXE scripts (one per boot target)
//! - `/ipxe/<file>` bundled iPXE binaries (for UEFI HTTP boot) //! - `/ipxe/<file>` bundled iPXE binaries (for UEFI HTTP boot)
//! - `/iso/<id>.iso` raw ISO file (with Range support) //! - `/iso/<id>.iso` raw ISO file (with Range support)
//! - `/iso/<id>/<path>` files inside the ISO (for wimboot WIM fetches //! - `/iso/<id>/<path>` files inside the ISO (for wimboot WIM fetches
//! and Linux kernel/initrd, without having to //! and Linux kernel/initrd, without having to
//! re-extract on every request) //! re-extract on every request)
//! //!
//! The `<id>/<path>` handler uses a read-only ISO9660 shim (see `iso_fs`) //! The `<id>/<path>` handler uses a read-only ISO9660 shim (see `iso_fs`)
//! that lseeks into the ISO on disk — so we never keep extracted copies. //! that lseeks into the ISO on disk — so we never keep extracted copies.
+2 -2
View File
@@ -12,7 +12,7 @@ use axum::{
Json, Json,
}; };
use futures::stream::{Stream, StreamExt}; use futures::stream::{Stream, StreamExt};
use pxeforge_core::LogLine; use openpxe_core::LogLine;
use serde_json::json; use serde_json::json;
use std::convert::Infallible; use std::convert::Infallible;
use std::time::Duration; use std::time::Duration;
@@ -57,7 +57,7 @@ pub async fn clear(State(state): State<AppState>) -> Json<serde_json::Value> {
state.log_bus.clear(); state.log_bus.clear();
state state
.log_bus .log_bus
.push("info", "pxeforge::terminal", "log buffer cleared by operator"); .push("info", "openpxe::terminal", "log buffer cleared by operator");
Json(json!({ "ok": true })) Json(json!({ "ok": true }))
} }
+10 -3
View File
@@ -1,5 +1,5 @@
use pxeforge_core::{ClientRegistry, GateQueue, LogBus, SettingsStore}; use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore};
use pxeforge_iso_store::{IsoStore, NfsManager, SmbManager}; use openpxe_iso_store::{IsoStore, NfsManager, SmbManager};
use std::sync::Arc; use std::sync::Arc;
use time::OffsetDateTime; use time::OffsetDateTime;
@@ -8,7 +8,14 @@ pub struct AppState {
pub iso_store: IsoStore, pub iso_store: IsoStore,
pub clients: Arc<ClientRegistry>, pub clients: Arc<ClientRegistry>,
pub settings: Arc<SettingsStore>, pub settings: Arc<SettingsStore>,
pub gates: Arc<GateQueue>, pub queue: Arc<DeploymentQueue>,
/// 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,
/// Lock-free metrics counters surfaced at `/metrics` in Prometheus
/// text format. Cheap to clone (handles to atomics).
pub metrics: Metrics,
/// Optional SMB manager. Present when the binary was given a writable /// Optional SMB manager. Present when the binary was given a writable
/// `smb_dir` at startup; `None` in pure-Linux-only deployments where /// `smb_dir` at startup; `None` in pure-Linux-only deployments where
/// Windows support is not wired in. Settings toggle drives start/stop. /// Windows support is not wired in. Settings toggle drives start/stop.
+37 -37
View File
@@ -36,7 +36,7 @@ pub async fn run_command(
// Echo the typed command into the live log so the Terminal tab shows // Echo the typed command into the live log so the Terminal tab shows
// operator activity in-band with server-emitted log lines. // operator activity in-band with server-emitted log lines.
state.log_bus.push("info", "pxeforge::terminal", format!("> {line}")); state.log_bus.push("info", "openpxe::terminal", format!("> {line}"));
let argv = shell_split(line); let argv = shell_split(line);
if argv.is_empty() { if argv.is_empty() {
@@ -60,9 +60,9 @@ pub async fn run_command(
output.clone() output.clone()
}; };
if ok { if ok {
state.log_bus.push("info", "pxeforge::terminal", mirror); state.log_bus.push("info", "openpxe::terminal", mirror);
} else { } else {
state.log_bus.push("warn", "pxeforge::terminal", mirror); state.log_bus.push("warn", "openpxe::terminal", mirror);
} }
(StatusCode::OK, Json(json!({ "output": output, "ok": ok }))) (StatusCode::OK, Json(json!({ "output": output, "ok": ok })))
@@ -73,12 +73,12 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
let tail = &argv[1..]; let tail = &argv[1..];
match head { match head {
"help" | "?" => Ok(HELP_TEXT.to_string()), "help" | "?" => Ok(HELP_TEXT.to_string()),
"version" => Ok(format!("pxeforge {}", env!("CARGO_PKG_VERSION"))), "version" => Ok(format!("openpxe {}", env!("CARGO_PKG_VERSION"))),
"uptime" => Ok(uptime_string(state)), "uptime" => Ok(uptime_string(state)),
"status" => Ok(status_text(state)), "status" => Ok(status_text(state)),
"isos" | "images" => Ok(isos_text(state)), "isos" | "images" => Ok(isos_text(state)),
"clients" => Ok(clients_text(state)), "clients" => Ok(clients_text(state)),
"gate" => gate_command(state, tail).await, "queue" => gate_command(state, tail).await,
"nfs" => nfs_command(state, tail).await, "nfs" => nfs_command(state, tail).await,
"smb" => smb_command(state, tail).await, "smb" => smb_command(state, tail).await,
"log" => log_command(state, tail), "log" => log_command(state, tail),
@@ -96,18 +96,18 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
fn status_text(s: &AppState) -> String { fn status_text(s: &AppState) -> String {
let isos = s.iso_store.list(); let isos = s.iso_store.list();
let clients = s.clients.list(); let clients = s.clients.list();
let gates = s.gates.list(); let gates = s.queue.list();
let smb = s.smb.as_ref().map(|m| m.snapshot()); let smb = s.smb.as_ref().map(|m| m.snapshot());
let nfs = s.nfs.list(); let nfs = s.nfs.list();
let nfs_active = nfs.iter().filter(|m| m.mounted).count(); let nfs_active = nfs.iter().filter(|m| m.mounted).count();
format!( format!(
"PXEForge {ver}\n\ "OpenPXE {ver}\n\
base url: {base}\n\ base url: {base}\n\
interface: {nic}\n\ interface: {nic}\n\
uptime: {up}\n\ uptime: {up}\n\
isos: {n_isos} (local: {n_local}, nfs: {n_nfs})\n\ isos: {n_isos} (local: {n_local}, nfs: {n_nfs})\n\
clients: {n_clients}\n\ clients: {n_clients}\n\
gates: {n_gates}\n\ queue: {n_entries}\n\
smb: {smb}\n\ smb: {smb}\n\
nfs mounts: {n_total} configured ({n_active} active)\n", nfs mounts: {n_total} configured ({n_active} active)\n",
ver = env!("CARGO_PKG_VERSION"), ver = env!("CARGO_PKG_VERSION"),
@@ -115,10 +115,10 @@ fn status_text(s: &AppState) -> String {
nic = if s.nic_name.is_empty() { "?" } else { s.nic_name.as_str() }, nic = if s.nic_name.is_empty() { "?" } else { s.nic_name.as_str() },
up = uptime_string(s), up = uptime_string(s),
n_isos = isos.len(), n_isos = isos.len(),
n_local = isos.iter().filter(|i| matches!(i.source, pxeforge_iso_store::IsoSource::Local)).count(), n_local = isos.iter().filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Local)).count(),
n_nfs = isos.iter().filter(|i| !matches!(i.source, pxeforge_iso_store::IsoSource::Local)).count(), n_nfs = isos.iter().filter(|i| !matches!(i.source, openpxe_iso_store::IsoSource::Local)).count(),
n_clients = clients.len(), n_clients = clients.len(),
n_gates = gates.len(), n_entries = gates.len(),
smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")), smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")),
n_total = nfs.len(), n_total = nfs.len(),
n_active = nfs_active, n_active = nfs_active,
@@ -138,8 +138,8 @@ fn isos_text(s: &AppState) -> String {
); );
for i in isos { for i in isos {
let src = match i.source { let src = match i.source {
pxeforge_iso_store::IsoSource::Local => "local".to_string(), openpxe_iso_store::IsoSource::Local => "local".to_string(),
pxeforge_iso_store::IsoSource::Nfs { mount_id, .. } => format!("nfs:{mount_id}"), openpxe_iso_store::IsoSource::Nfs { mount_id, .. } => format!("nfs:{mount_id}"),
}; };
let _ = writeln!( let _ = writeln!(
out, out,
@@ -161,8 +161,8 @@ fn clients_text(s: &AppState) -> String {
let mut out = String::new(); let mut out = String::new();
let _ = writeln!( let _ = writeln!(
out, out,
"{:<19} {:<16} {:<8} {}", "{:<19} {:<16} {:<8} LAST SEEN",
"MAC", "IP", "EVENTS", "LAST SEEN" "MAC", "IP", "EVENTS"
); );
for c in clients { for c in clients {
let ip = c.last_ip.map_or_else(|| "-".into(), |i| i.to_string()); let ip = c.last_ip.map_or_else(|| "-".into(), |i| i.to_string());
@@ -188,7 +188,7 @@ fn clients_text(s: &AppState) -> String {
async fn gate_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) { match args.first().map(String::as_str) {
None | Some("list") => { None | Some("list") => {
let gs = s.gates.list(); let gs = s.queue.list();
if gs.is_empty() { if gs.is_empty() {
return Ok("(no gates)".into()); return Ok("(no gates)".into());
} }
@@ -217,28 +217,28 @@ async fn gate_command(s: &AppState, args: &[String]) -> Result<String, String> {
if !found { if !found {
return Err(format!("no such boot entry: {target}")); return Err(format!("no such boot entry: {target}"));
} }
let ids: Vec<_> = s.gates.list().into_iter().map(|g| g.id).collect(); let ids: Vec<_> = s.queue.list().into_iter().map(|g| g.id).collect();
let n = s.gates.assign(&ids, target); let n = s.queue.assign(&ids, target);
Ok(format!("assigned {n} gates -> {target}")) Ok(format!("assigned {n} gates -> {target}"))
} }
Some("assign") => { Some("assign") => {
let gate_id = args let entry_id = args
.get(1) .get(1)
.ok_or_else(|| "usage: gate assign <gate_id> <iso_boot_entry_id>".to_string())?; .ok_or_else(|| "usage: gate assign <entry_id> <iso_boot_entry_id>".to_string())?;
let target = args let target = args
.get(2) .get(2)
.ok_or_else(|| "usage: gate assign <gate_id> <iso_boot_entry_id>".to_string())?; .ok_or_else(|| "usage: gate assign <entry_id> <iso_boot_entry_id>".to_string())?;
let n = s.gates.assign(std::slice::from_ref(gate_id), target); let n = s.queue.assign(std::slice::from_ref(entry_id), target);
if n == 0 { if n == 0 {
return Err(format!("no such gate: {gate_id}")); return Err(format!("no such gate: {entry_id}"));
} }
Ok(format!("assigned 1 gate -> {target}")) Ok(format!("assigned 1 gate -> {target}"))
} }
Some("release") => { Some("release") => {
let gate_id = args.get(1).ok_or_else(|| "usage: gate release <gate_id>".to_string())?; let entry_id = args.get(1).ok_or_else(|| "usage: gate release <entry_id>".to_string())?;
match s.gates.release(gate_id) { match s.queue.release(entry_id) {
Some(_) => Ok(format!("released {gate_id}")), Some(_) => Ok(format!("released {entry_id}")),
None => Err(format!("no such gate: {gate_id}")), None => Err(format!("no such gate: {entry_id}")),
} }
} }
Some(other) => Err(format!( Some(other) => Err(format!(
@@ -259,8 +259,8 @@ async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
let mut out = String::new(); let mut out = String::new();
let _ = writeln!( let _ = writeln!(
out, out,
"{:<24} {:<6} {:<7} {:<6} {}", "{:<24} {:<6} {:<7} {:<6} TARGET",
"ID", "VER", "STATUS", "ISOS", "TARGET" "ID", "VER", "STATUS", "ISOS"
); );
for m in mounts { for m in mounts {
let status = if m.mounted { "ok" } else { "down" }; let status = if m.mounted { "ok" } else { "down" };
@@ -269,8 +269,8 @@ async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
"{:<24} {:<6} {:<7} {:<6} {}:{}", "{:<24} {:<6} {:<7} {:<6} {}:{}",
truncate(&m.id, 24), truncate(&m.id, 24),
match m.version { match m.version {
pxeforge_iso_store::NfsVersion::V3 => "v3", openpxe_iso_store::NfsVersion::V3 => "v3",
pxeforge_iso_store::NfsVersion::V41 => "v4.1", openpxe_iso_store::NfsVersion::V41 => "v4.1",
}, },
status, status,
m.iso_count, m.iso_count,
@@ -292,12 +292,12 @@ async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
.split_once(':') .split_once(':')
.ok_or_else(|| "target must be 'server:/export'".to_string())?; .ok_or_else(|| "target must be 'server:/export'".to_string())?;
let version = match args.get(2).map(String::as_str) { let version = match args.get(2).map(String::as_str) {
Some("v3") => pxeforge_iso_store::NfsVersion::V3, Some("v3") => openpxe_iso_store::NfsVersion::V3,
Some("v41") | None => pxeforge_iso_store::NfsVersion::V41, Some("v41") | None => openpxe_iso_store::NfsVersion::V41,
Some(other) => return Err(format!("unknown nfs version: {other} (expect v3 or v41)")), Some(other) => return Err(format!("unknown nfs version: {other} (expect v3 or v41)")),
}; };
let read_only = !matches!(args.get(3).map(String::as_str), Some("rw")); let read_only = !matches!(args.get(3).map(String::as_str), Some("rw"));
let req = pxeforge_iso_store::NfsAddRequest { let req = openpxe_iso_store::NfsAddRequest {
server: server.to_string(), server: server.to_string(),
export: export.to_string(), export: export.to_string(),
version, version,
@@ -453,7 +453,7 @@ pub fn shell_split(input: &str) -> Vec<String> {
} }
const HELP_TEXT: &str = "\ const HELP_TEXT: &str = "\
PXEForge terminal — available commands: OpenPXE terminal — available commands:
help show this help help show this help
version print server version version print server version
@@ -463,9 +463,9 @@ PXEForge terminal — available commands:
isos list registered ISOs isos list registered ISOs
clients list PXE clients seen this session clients list PXE clients seen this session
gate list list gated-deployment queue gate list list gated-deployment queue
gate assign <gate_id> <target> assign one gate to a boot entry gate assign <entry_id> <target> assign one gate to a boot entry
gate assign-all <target> assign every waiting gate gate assign-all <target> assign every waiting gate
gate release <gate_id> release one gate gate release <entry_id> release one gate
nfs list list NFS mounts nfs list list NFS mounts
nfs mount <s>:<e> [v3|v41] [ro|rw] add and mount an NFS share nfs mount <s>:<e> [v3|v41] [ro|rw] add and mount an NFS share
+225 -21
View File
@@ -3,7 +3,7 @@
//! Spins up the real axum router against a temp ISO store + settings store, //! Spins up the real axum router against a temp ISO store + settings store,
//! then walks an imaginary iPXE client through: dashboard status → upload //! then walks an imaginary iPXE client through: dashboard status → upload
//! ISO → fetch top-level boot menu → fetch per-entry script → Range-GET the //! ISO → fetch top-level boot menu → fetch per-entry script → Range-GET the
//! ISO. Also drives the Gated Deployment flow end-to-end: two clients join, //! ISO. Also drives the Queued Deployment flow end-to-end: two clients join,
//! operator assigns, both polls return the chain script with retry fallback. //! operator assigns, both polls return the chain script with retry fallback.
//! //!
//! This is the closest we can get to "real PXE client" without QEMU; the //! This is the closest we can get to "real PXE client" without QEMU; the
@@ -12,9 +12,9 @@
use axum::body::Body; use axum::body::Body;
use axum::http::{header, Request, StatusCode}; use axum::http::{header, Request, StatusCode};
use pxeforge_core::{ClientRegistry, GateQueue, LogBus, SettingsStore}; use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore};
use pxeforge_http_api::{build_router, AppState}; use openpxe_http_api::{build_router, AppState};
use pxeforge_iso_store::{IsoStore, NfsManager}; use openpxe_iso_store::{IsoStore, NfsManager};
use tempfile::tempdir; use tempfile::tempdir;
use tower::ServiceExt; use tower::ServiceExt;
@@ -87,16 +87,20 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
let iso_store = IsoStore::new(dir.path().join("isos")); let iso_store = IsoStore::new(dir.path().join("isos"));
iso_store.ensure_dirs().await.unwrap(); iso_store.ensure_dirs().await.unwrap();
let clients = ClientRegistry::new(); let clients = ClientRegistry::new();
let gates = GateQueue::new(); let gates = DeploymentQueue::new();
let settings = SettingsStore::load_or_default(dir.path()); let settings = SettingsStore::load_or_default(dir.path());
let nfs = NfsManager::new(dir.path(), iso_store.clone()); let nfs = NfsManager::new(dir.path(), iso_store.clone());
iso_store.set_nfs_root(nfs.mount_root()); iso_store.set_nfs_root(nfs.mount_root());
let log_bus = LogBus::new(64); let log_bus = LogBus::new(64);
let hosts = HostBindings::load_or_default(dir.path());
let metrics = Metrics::new();
let state = AppState { let state = AppState {
iso_store, iso_store,
clients, clients,
gates, queue: gates,
settings, settings,
hosts,
metrics,
smb: None, smb: None,
nfs, nfs,
log_bus, log_bus,
@@ -194,7 +198,7 @@ async fn iso_range_request_slices_correctly() {
} }
#[tokio::test] #[tokio::test]
async fn gated_deployment_full_flow() { async fn queued_deployment_full_flow() {
let (state, _dir) = build_state().await; let (state, _dir) = build_state().await;
let app = build_router(state.clone()); let app = build_router(state.clone());
@@ -206,22 +210,22 @@ async fn gated_deployment_full_flow() {
.await.unwrap(); .await.unwrap();
// Two clients join. // Two clients join.
let (_, join1) = get(&app, "/api/gate/join?mac=aa:bb:cc:00:00:01").await; let (_, join1) = get(&app, "/api/queue/join?mac=aa:bb:cc:00:00:01").await;
let (_, join2) = get(&app, "/api/gate/join?mac=aa:bb:cc:00:00:02").await; let (_, join2) = get(&app, "/api/queue/join?mac=aa:bb:cc:00:00:02").await;
let s1 = String::from_utf8(join1).unwrap(); let s1 = String::from_utf8(join1).unwrap();
let s2 = String::from_utf8(join2).unwrap(); let s2 = String::from_utf8(join2).unwrap();
assert!(s1.contains("Gate Position 1")); assert!(s1.contains("Gate Position 1"));
assert!(s2.contains("Gate Position 2")); assert!(s2.contains("Gate Position 2"));
let gate1_id = s1.lines().find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/gate/poll/")) let gate1_id = s1.lines().find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/queue/poll/"))
.unwrap().to_string(); .unwrap().to_string();
let gate2_id = s2.lines().find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/gate/poll/")) let gate2_id = s2.lines().find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/queue/poll/"))
.unwrap().to_string(); .unwrap().to_string();
// Kick off a long-poll for client 1 in the background. Then assign. // Kick off a long-poll for client 1 in the background. Then assign.
let app2 = app.clone(); let app2 = app.clone();
let poll_future = tokio::spawn(async move { let poll_future = tokio::spawn(async move {
let uri = format!("/api/gate/poll/{gate1_id}"); let uri = format!("/api/queue/poll/{gate1_id}");
get(&app2, &uri).await get(&app2, &uri).await
}); });
@@ -229,15 +233,15 @@ async fn gated_deployment_full_flow() {
tokio::time::sleep(std::time::Duration::from_millis(50)).await; tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// Operator assigns. // Operator assigns.
let body = format!(r#"{{"target":"fake-alpine-linux","gate_ids":["{gate2_id}"]}}"#); let body = format!(r#"{{"target":"fake-alpine-linux","entry_ids":["{gate2_id}"]}}"#);
let (s, b) = post_json(&app, "/api/gate/assign", &body).await; let (s, b) = post_json(&app, "/api/queue/assign", &body).await;
assert_eq!(s, StatusCode::OK); assert_eq!(s, StatusCode::OK);
let assign_json = String::from_utf8(b).unwrap(); let assign_json = String::from_utf8(b).unwrap();
assert!(assign_json.contains(r#""assigned":1"#), "assign response: {assign_json}"); assert!(assign_json.contains(r#""assigned":1"#), "assign response: {assign_json}");
// Now assign to gate 1 too so the background poll wakes. // Now assign to gate 1 too so the background poll wakes.
let body = format!(r#"{{"target":"fake-alpine-linux","gate_ids":[]}}"#); let body = r#"{"target":"fake-alpine-linux","entry_ids":[]}"#;
post_json(&app, "/api/gate/assign", &body).await; post_json(&app, "/api/queue/assign", body).await;
let (poll_status, poll_body) = poll_future.await.unwrap(); let (poll_status, poll_body) = poll_future.await.unwrap();
assert_eq!(poll_status, StatusCode::OK); assert_eq!(poll_status, StatusCode::OK);
@@ -247,12 +251,12 @@ async fn gated_deployment_full_flow() {
"poll response should chain the boot script:\n{poll_s}" "poll response should chain the boot script:\n{poll_s}"
); );
// Retry-on-error fallback must be present. // Retry-on-error fallback must be present.
assert!(poll_s.contains("|| chain http://127.0.0.1/api/gate/poll/"), assert!(poll_s.contains("|| chain http://127.0.0.1/api/queue/poll/"),
"retry fallback missing"); "retry fallback missing");
// Bad target must be rejected. // Bad target must be rejected.
let (_, bad) = post_json(&app, "/api/gate/assign", let (_, bad) = post_json(&app, "/api/queue/assign",
r#"{"target":"does-not-exist","gate_ids":[]}"#).await; r#"{"target":"does-not-exist","entry_ids":[]}"#).await;
let bad_s = String::from_utf8(bad).unwrap(); let bad_s = String::from_utf8(bad).unwrap();
assert!(bad_s.contains(r#""ok":false"#), "expected rejection: {bad_s}"); assert!(bad_s.contains(r#""ok":false"#), "expected rejection: {bad_s}");
} }
@@ -325,6 +329,7 @@ async fn ui_assets_served_offline() {
("/assets/app.js", "application/javascript"), ("/assets/app.js", "application/javascript"),
("/assets/app.css", "text/css"), ("/assets/app.css", "text/css"),
("/assets/logo.svg", "image/svg+xml"), ("/assets/logo.svg", "image/svg+xml"),
("/assets/loader.svg", "image/svg+xml"),
] { ] {
let res = app let res = app
.clone() .clone()
@@ -401,14 +406,14 @@ async fn terminal_help_and_status_round_trip() {
let (s, b) = post_json(&app, "/api/terminal", r#"{"command":""}"#).await; let (s, b) = post_json(&app, "/api/terminal", r#"{"command":""}"#).await;
assert_eq!(s, StatusCode::OK); assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert!(v["output"].as_str().unwrap().contains("PXEForge terminal")); assert!(v["output"].as_str().unwrap().contains("OpenPXE terminal"));
// status -> contains the version banner. // status -> contains the version banner.
let (s, b) = post_json(&app, "/api/terminal", r#"{"command":"status"}"#).await; let (s, b) = post_json(&app, "/api/terminal", r#"{"command":"status"}"#).await;
assert_eq!(s, StatusCode::OK); assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap(); let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
let out = v["output"].as_str().unwrap(); let out = v["output"].as_str().unwrap();
assert!(out.starts_with("PXEForge"), "unexpected status output: {out}"); assert!(out.starts_with("OpenPXE"), "unexpected status output: {out}");
assert!(out.contains("isos:"), "status missing iso line: {out}"); assert!(out.contains("isos:"), "status missing iso line: {out}");
// Unknown command -> ok=false plus help hint. // Unknown command -> ok=false plus help hint.
@@ -438,6 +443,205 @@ async fn log_recent_returns_buffered_lines() {
} }
} }
#[tokio::test]
async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
// Synthesize an ISO with a Windows volume label + the sources/boot.wim
// sentinel so introspection labels it WindowsPe with has_boot_wim.
let mut buf = vec![0u8; 32 * 2048];
let off = 16 * 2048;
buf[off] = 0x01;
buf[off + 1..off + 6].copy_from_slice(b"CD001");
buf[off + 6] = 0x01;
let label = b"WIN11_X64".to_vec();
let mut padded = label.clone();
padded.resize(32, b' ');
buf[off + 40..off + 40 + 32].copy_from_slice(&padded);
// Sprinkle the sources/boot.wim sentinel where the introspection
// scanner will find it (anywhere in the first 64 MB).
let sentinel = b"SOURCES\\BOOT.WIM";
buf.extend_from_slice(sentinel);
let term = 17 * 2048;
buf[term] = 0xFF;
buf[term + 1..term + 6].copy_from_slice(b"CD001");
buf[term + 6] = 0x01;
let (state, _dir) = build_state().await;
let app = build_router(state);
// Need windows_enabled for the Windows path to render in the menu.
let res = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/settings")
.header("content-type", "application/json")
.body(Body::from(
r#"{"boot_menu_timeout_secs":600,"timeout_action":"queued_deployment",
"windows_enabled":false,"smb_host_override":"","extra_kernel_args":"",
"default_local_hdd":true,"gate_wait_max_secs":0,"dns_server":""}"#
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
// wimboot binary is bundled in this repo so windows_enabled=true should
// not be rejected; we leave it false to keep the upload path agnostic.
assert_eq!(res.status(), StatusCode::NO_CONTENT);
let (ct, body) = multipart_iso_body("Win11_x64.iso", &buf);
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/isos")
.header("content-type", ct)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::CREATED);
let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
let meta: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(meta["introspection"]["family"], "windows_pe");
assert!(
meta["introspection"]["has_boot_wim"].as_bool().unwrap(),
"introspection should detect sources/boot.wim sentinel"
);
// The boot entry should be a wimboot kind with the canonical 5-file
// chain documented in the LinusTechTips iPXE-Windows guide.
let entry = &meta["boot_entries"][0];
assert_eq!(entry["kind"]["kind"], "wimboot");
let files = entry["kind"]["files"].as_array().unwrap();
let names: Vec<&str> = files.iter().map(|f| f[0].as_str().unwrap()).collect();
assert!(names.contains(&"bootmgr"));
assert!(names.contains(&"bootmgr.efi"));
assert!(names.contains(&"bcd"));
assert!(names.contains(&"boot.sdi"));
assert!(names.contains(&"boot.wim"));
// Render the entry script and verify:
// 1. It uses wimboot
// 2. All 5 files are referenced via `initrd --name`
// 3. NO trust-store / driver / testsigning operations slip in
let entry_id = entry["id"].as_str().unwrap();
let url = format!("/boot/{entry_id}.ipxe");
let (s, body) = get(&app, &url).await;
assert_eq!(s, StatusCode::OK);
let script = String::from_utf8(body).unwrap();
assert!(script.contains("kernel "), "missing kernel line:\n{script}");
assert!(script.contains("ipxe/wimboot"), "missing wimboot loader:\n{script}");
for tag in ["bootmgr", "bootmgr.efi", "bcd", "boot.sdi", "boot.wim"] {
assert!(
script.contains(&format!("initrd --name {tag}")),
"missing `initrd --name {tag}` line:\n{script}"
);
}
// Hard guarantees we never want to see in any client-facing script.
let lower = script.to_lowercase();
for forbidden in [
"bcdedit", "testsigning", "certutil", "test-signed",
"httpdisk", "/set testsigning",
] {
assert!(
!lower.contains(forbidden),
"forbidden trust-store operation `{forbidden}` in script:\n{script}"
);
}
}
#[tokio::test]
async fn host_binding_short_circuits_boot_menu() {
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
// Pin a MAC to the reserved local-hdd boot shortcut. `_local` is a
// built-in target so the upsert validator accepts it without
// requiring a real ISO.
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hosts")
.header("content-type", "application/json")
.body(Body::from(
r#"{"mac":"AA:BB:CC:00:00:01","target":"_local","label":"toms-laptop"}"#
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::CREATED);
// Hit /boot.ipxe with the bound MAC and assert we get the
// short-circuit chain instead of the menu.
let (s1, b1) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:01").await;
assert_eq!(s1, StatusCode::OK);
let body1 = String::from_utf8(b1).unwrap();
assert!(
body1.contains("per-MAC binding"),
"expected MAC short-circuit, got:\n{body1}"
);
assert!(body1.contains("/boot/_local.ipxe"));
// And a different MAC still gets the menu.
let (s2, b2) = get(&app, "/boot.ipxe?mac=ff:ff:ff:ff:ff:ff").await;
assert_eq!(s2, StatusCode::OK);
let body2 = String::from_utf8(b2).unwrap();
assert!(
body2.contains("menu") || body2.contains("Default"),
"expected interactive menu, got:\n{body2}"
);
}
#[tokio::test]
async fn metrics_endpoint_emits_prometheus_format() {
let (state, _dir) = build_state().await;
let app = build_router(state);
// Drive a couple of paths so counters move off zero.
let _ = get(&app, "/api/status").await;
let _ = get(&app, "/boot.ipxe").await;
let res = app
.clone()
.oneshot(Request::builder().uri("/metrics").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let ct = res.headers().get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
assert!(
ct.starts_with("text/plain"),
"wrong content-type: {ct}"
);
let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
let body = String::from_utf8(body.to_vec()).unwrap();
// Spot-check the must-have metric families.
for name in [
"openpxe_dhcp_replies_total",
"openpxe_tftp_transfers_total",
"openpxe_http_requests_total",
"openpxe_iso_count",
"openpxe_uptime_seconds",
"openpxe_build_info",
] {
assert!(body.contains(name), "missing metric {name} in:\n{body}");
}
// Each name appears exactly once as a `# TYPE` declaration.
for name in [
"openpxe_dhcp_replies_total",
"openpxe_iso_count",
] {
let count = body.matches(&format!("# TYPE {name}")).count();
assert_eq!(count, 1, "{name} TYPE line appears {count} times");
}
}
#[tokio::test] #[tokio::test]
async fn network_endpoint_exposes_dns_round_trip() { async fn network_endpoint_exposes_dns_round_trip() {
let (state, _dir) = build_state().await; let (state, _dir) = build_state().await;
+3 -3
View File
@@ -1,16 +1,16 @@
[package] [package]
name = "pxeforge-ipxe-assets" name = "openpxe-ipxe-assets"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
description = "Bundled iPXE binaries and default chain scripts for PXEForge" description = "Bundled iPXE binaries and default chain scripts for OpenPXE"
[lints] [lints]
workspace = true workspace = true
[dependencies] [dependencies]
pxeforge-core.workspace = true openpxe-core.workspace = true
rust-embed.workspace = true rust-embed.workspace = true
tracing.workspace = true tracing.workspace = true
thiserror.workspace = true thiserror.workspace = true
+13 -5
View File
@@ -1,7 +1,7 @@
//! Bundled iPXE boot binaries and default chain script. //! Bundled iPXE boot binaries and default chain script.
//! //!
//! At build time, we expect the iPXE binaries to live at `assets/ipxe/` at //! At build time, we expect the iPXE binaries to live at `assets/ipxe/` at
//! the workspace root. They are embedded into the PXEForge binary via //! the workspace root. They are embedded into the OpenPXE binary via
//! `rust-embed` so the container image is self-contained. If a binary is //! `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 //! missing, that architecture simply won't have PXE support — we log at
//! startup and serve what we have. //! startup and serve what we have.
@@ -16,7 +16,7 @@
//! - `wimboot` — Windows boot shim (fetched separately for WIM chains) //! - `wimboot` — Windows boot shim (fetched separately for WIM chains)
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
use pxeforge_core::ClientArch; use openpxe_core::ClientArch;
use rust_embed::Embed; use rust_embed::Embed;
#[derive(Embed)] #[derive(Embed)]
@@ -40,10 +40,18 @@ pub fn asset_bytes(name: &str) -> Option<Vec<u8>> {
IpxeAssets::get(name).map(|f| f.data.into_owned()) IpxeAssets::get(name).map(|f| f.data.into_owned())
} }
/// Same as [`asset_bytes`] but returns the embedded slice directly,
/// avoiding the heap copy when the caller only needs to read the
/// payload. Falls back to None for unknown names.
#[must_use]
pub fn asset_slice(name: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
IpxeAssets::get(name).map(|f| f.data)
}
/// Enumerate embedded asset filenames. Useful for startup logging so the /// Enumerate embedded asset filenames. Useful for startup logging so the
/// operator can immediately tell which architectures will work. /// operator can immediately tell which architectures will work.
pub fn list_assets() -> Vec<String> { pub fn list_assets() -> Vec<String> {
IpxeAssets::iter().map(|c| c.into_owned()).collect() IpxeAssets::iter().map(std::borrow::Cow::into_owned).collect()
} }
/// Log at startup which iPXE binaries are present and which are missing. /// Log at startup which iPXE binaries are present and which are missing.
@@ -58,10 +66,10 @@ pub fn log_availability() {
]; ];
for (arch, name) in needed { for (arch, name) in needed {
if have.contains(name) { if have.contains(name) {
tracing::info!(target: "pxeforge::ipxe", "bundled iPXE for {}: {}", arch.as_str(), name); tracing::info!(target: "openpxe::ipxe", "bundled iPXE for {}: {}", arch.as_str(), name);
} else { } else {
tracing::warn!( tracing::warn!(
target: "pxeforge::ipxe", target: "openpxe::ipxe",
"MISSING iPXE binary for {}: {} — clients of this arch will not PXE boot", "MISSING iPXE binary for {}: {} — clients of this arch will not PXE boot",
arch.as_str(), name arch.as_str(), name
); );
+3 -3
View File
@@ -1,16 +1,16 @@
[package] [package]
name = "pxeforge-iso-store" name = "openpxe-iso-store"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
description = "ISO upload, storage, introspection, and boot-entry generation for PXEForge" description = "ISO upload, storage, introspection, and boot-entry generation for OpenPXE"
[lints] [lints]
workspace = true workspace = true
[dependencies] [dependencies]
pxeforge-core.workspace = true openpxe-core.workspace = true
tokio = { workspace = true } tokio = { workspace = true }
tokio-util = { workspace = true } tokio-util = { workspace = true }
serde.workspace = true serde.workspace = true
+2 -2
View File
@@ -49,7 +49,7 @@ pub fn introspect(path: &Path) -> IntrospectionReport {
}; };
let Ok(mut f) = std::fs::File::open(path) else { let Ok(mut f) = std::fs::File::open(path) else {
tracing::warn!(target: "pxeforge::iso", "cannot open ISO for introspection: {}", path.display()); tracing::warn!(target: "openpxe::iso", "cannot open ISO for introspection: {}", path.display());
return report; return report;
}; };
@@ -98,7 +98,7 @@ pub fn introspect(path: &Path) -> IntrospectionReport {
// that happens in the store after introspection. // that happens in the store after introspection.
let (k, i) = guess_kernel_initrd(report.family); let (k, i) = guess_kernel_initrd(report.family);
report.kernel_path = k.map(str::to_string); report.kernel_path = k.map(str::to_string);
report.initrd_paths = i.iter().map(|s| s.to_string()).collect(); report.initrd_paths = i.iter().map(std::string::ToString::to_string).collect();
report report
} }
+9 -9
View File
@@ -37,7 +37,7 @@
use crate::introspect::{introspect, IntrospectionReport}; use crate::introspect::{introspect, IntrospectionReport};
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore}; use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
use parking_lot::Mutex; use parking_lot::Mutex;
use pxeforge_core::{Error, Result}; use openpxe_core::{Error, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -76,7 +76,7 @@ pub struct NfsMount {
pub export: String, pub export: String,
pub version: NfsVersion, pub version: NfsVersion,
/// Read-only by default — most ISO libraries are. Operators that need /// Read-only by default — most ISO libraries are. Operators that need
/// write can flip this off but PXEForge itself never writes. /// write can flip this off but OpenPXE itself never writes.
pub read_only: bool, pub read_only: bool,
/// Local mount point under `<work_dir>/nfs/`. /// Local mount point under `<work_dir>/nfs/`.
pub local_path: PathBuf, pub local_path: PathBuf,
@@ -168,7 +168,7 @@ impl NfsManager {
self.inner.lock().mounts.insert(m.id.clone(), m.clone()); self.inner.lock().mounts.insert(m.id.clone(), m.clone());
if let Err(e) = self.try_mount(&m.id).await { if let Err(e) = self.try_mount(&m.id).await {
tracing::warn!( tracing::warn!(
target: "pxeforge::nfs", target: "openpxe::nfs",
id = %m.id, error = %e, id = %m.id, error = %e,
"could not remount NFS share on startup" "could not remount NFS share on startup"
); );
@@ -299,7 +299,7 @@ impl NfsManager {
match output { match output {
Ok(out) if out.status.success() => { Ok(out) if out.status.success() => {
tracing::info!( tracing::info!(
target: "pxeforge::nfs", target: "openpxe::nfs",
id = %id, server = %m.server, export = %m.export, id = %id, server = %m.server, export = %m.export,
version = ?m.version, version = ?m.version,
"NFS mount succeeded" "NFS mount succeeded"
@@ -315,13 +315,13 @@ impl NfsManager {
out.status.code().unwrap_or(-1), out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stderr).trim() String::from_utf8_lossy(&out.stderr).trim()
); );
tracing::warn!(target: "pxeforge::nfs", id = %id, "{err}"); tracing::warn!(target: "openpxe::nfs", id = %id, "{err}");
self.update_status(id, false, Some(err.clone()), now); self.update_status(id, false, Some(err.clone()), now);
Err(Error::Invalid(err)) Err(Error::Invalid(err))
} }
Err(e) => { Err(e) => {
let err = format!("could not exec /bin/mount: {e}"); let err = format!("could not exec /bin/mount: {e}");
tracing::error!(target: "pxeforge::nfs", id = %id, "{err}"); tracing::error!(target: "openpxe::nfs", id = %id, "{err}");
self.update_status(id, false, Some(err.clone()), now); self.update_status(id, false, Some(err.clone()), now);
Err(Error::Invalid(err)) Err(Error::Invalid(err))
} }
@@ -442,7 +442,7 @@ impl NfsManager {
let body = match serde_json::to_vec_pretty(&mounts) { let body = match serde_json::to_vec_pretty(&mounts) {
Ok(b) => b, Ok(b) => b,
Err(e) => { Err(e) => {
tracing::warn!(target: "pxeforge::nfs", "serialize NFS state: {e}"); tracing::warn!(target: "openpxe::nfs", "serialize NFS state: {e}");
return; return;
} }
}; };
@@ -450,11 +450,11 @@ impl NfsManager {
let _ = std::fs::create_dir_all(parent); let _ = std::fs::create_dir_all(parent);
} }
if let Err(e) = std::fs::write(&tmp, body) { if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "pxeforge::nfs", "write NFS state tmp: {e}"); tracing::warn!(target: "openpxe::nfs", "write NFS state tmp: {e}");
return; return;
} }
if let Err(e) = std::fs::rename(&tmp, path) { if let Err(e) = std::fs::rename(&tmp, path) {
tracing::warn!(target: "pxeforge::nfs", "rename NFS state: {e}"); tracing::warn!(target: "openpxe::nfs", "rename NFS state: {e}");
} }
} }
} }
+24 -16
View File
@@ -20,7 +20,7 @@
//! - SMB2 minimum (no SMB1 legacy, not needed for WinPE). //! - SMB2 minimum (no SMB1 legacy, not needed for WinPE).
//! - Bound to 0.0.0.0:445; operator MUST put this on a trusted install //! - Bound to 0.0.0.0:445; operator MUST put this on a trusted install
//! VLAN — guest SMB is not for the general internet. //! VLAN — guest SMB is not for the general internet.
//! - smbd runs as the same non-root uid as pxeforge (10001). //! - smbd runs as the same non-root uid as openpxe (10001).
//! - If `smbd` isn't on PATH (e.g. lightweight container build without //! - If `smbd` isn't on PATH (e.g. lightweight container build without
//! Samba), we return `SmbState::SmbdMissing` and the UI surfaces the //! Samba), we return `SmbState::SmbdMissing` and the UI surfaces the
//! gap. No panics, no retries, no silent failure. //! gap. No panics, no retries, no silent failure.
@@ -87,23 +87,28 @@ impl SmbManager {
/// Write out `smb.conf` for the currently-discovered shares. Safe to /// Write out `smb.conf` for the currently-discovered shares. Safe to
/// call while smbd is running — smbd reloads on SIGHUP. /// call while smbd is running — smbd reloads on SIGHUP.
pub fn write_conf(&self) -> std::io::Result<Vec<String>> { pub fn write_conf(&self) -> std::io::Result<Vec<String>> {
use std::fmt::Write as _;
std::fs::create_dir_all(&self.smb_dir)?; std::fs::create_dir_all(&self.smb_dir)?;
let shares = self.discover_shares(); let shares = self.discover_shares();
let mut conf = String::new(); let mut conf = String::new();
conf.push_str(SMB_CONF_GLOBAL); conf.push_str(SMB_CONF_GLOBAL);
for name in &shares { for name in &shares {
let path = self.smb_dir.join(name); let path = self.smb_dir.join(name);
conf.push_str(&format!( // Per-share block. `write!` to String never fails — the unwrap
// is provably unreachable, but expect() makes that explicit.
write!(
conf,
"\n[{name}]\n\ "\n[{name}]\n\
path = {}\n\ path = {}\n\
comment = PXEForge Windows install media ({name})\n\ comment = OpenPXE Windows install media ({name})\n\
read only = yes\n\ read only = yes\n\
guest ok = yes\n\ guest ok = yes\n\
guest only = yes\n\ guest only = yes\n\
browseable = yes\n\ browseable = yes\n\
available = yes\n", available = yes\n",
path.display(), path.display(),
)); )
.expect("writing to a String is infallible");
} }
let tmp = self.conf_path.with_extension("conf.tmp"); let tmp = self.conf_path.with_extension("conf.tmp");
std::fs::write(&tmp, conf)?; std::fs::write(&tmp, conf)?;
@@ -114,7 +119,7 @@ impl SmbManager {
/// Start smbd. No-op if already running. /// Start smbd. No-op if already running.
pub fn start(&self) -> SmbState { pub fn start(&self) -> SmbState {
let mut g = self.child.lock(); let mut g = self.child.lock();
if g.as_ref().map_or(false, |c| c.id() > 0) { if g.as_ref().is_some_and(|c| c.id() > 0) {
return self.state.lock().clone(); return self.state.lock().clone();
} }
if !smbd_present() { if !smbd_present() {
@@ -147,7 +152,7 @@ impl SmbManager {
*g = Some(c); *g = Some(c);
let s = SmbState::Running { pid, shares }; let s = SmbState::Running { pid, shares };
*self.state.lock() = s.clone(); *self.state.lock() = s.clone();
tracing::info!(target: "pxeforge::smb", pid, shares=?self.state.lock(), "smbd started"); tracing::info!(target: "openpxe::smb", pid, shares=?self.state.lock(), "smbd started");
s s
} }
Err(e) => { Err(e) => {
@@ -173,7 +178,10 @@ impl SmbManager {
} }
}; };
if let Some(c) = g.as_mut() { if let Some(c) = g.as_mut() {
let pid = c.id() as i32; // u32 -> i32 for libc::kill. We never spawn enough children
// for the pid to overflow i32; cast_signed makes the intent
// explicit and silences the lint.
let pid = c.id().cast_signed();
// SAFETY: libc::kill is FFI-safe; we pass a pid we own (returned // SAFETY: libc::kill is FFI-safe; we pass a pid we own (returned
// from `Child::id` above, the child is alive because we hold the // from `Child::id` above, the child is alive because we hold the
// Mutex guard `g`) and a well-defined signal constant. Return // Mutex guard `g`) and a well-defined signal constant. Return
@@ -211,7 +219,7 @@ fn smbd_present() -> bool {
false false
} }
const SMB_CONF_GLOBAL: &str = r#"[global] const SMB_CONF_GLOBAL: &str = r"[global]
workgroup = PXEFORGE workgroup = PXEFORGE
server min protocol = SMB2 server min protocol = SMB2
smb ports = 445 smb ports = 445
@@ -227,7 +235,7 @@ lock directory = /tmp
state directory = /tmp state directory = /tmp
cache directory = /tmp cache directory = /tmp
pid directory = /tmp pid directory = /tmp
"#; ";
/// Extract a Windows ISO at `iso_path` into `smb_dir/<slug>/`. Uses /// Extract a Windows ISO at `iso_path` into `smb_dir/<slug>/`. Uses
/// `7z` when available (most reliable for UDF + ISO9660 hybrid images); /// `7z` when available (most reliable for UDF + ISO9660 hybrid images);
@@ -240,7 +248,7 @@ pid directory = /tmp
pub fn extract_windows_iso(iso_path: &Path, smb_dir: &Path, slug: &str) -> std::io::Result<PathBuf> { pub fn extract_windows_iso(iso_path: &Path, smb_dir: &Path, slug: &str) -> std::io::Result<PathBuf> {
let target = smb_dir.join(slug); let target = smb_dir.join(slug);
if target.join("sources").join("boot.wim").is_file() { if target.join("sources").join("boot.wim").is_file() {
tracing::debug!(target: "pxeforge::smb", slug, "ISO already extracted, skipping"); tracing::debug!(target: "openpxe::smb", slug, "ISO already extracted, skipping");
return Ok(target); return Ok(target);
} }
std::fs::create_dir_all(&target)?; std::fs::create_dir_all(&target)?;
@@ -257,7 +265,7 @@ pub fn extract_windows_iso(iso_path: &Path, smb_dir: &Path, slug: &str) -> std::
.output()?; .output()?;
if out.status.success() { return Ok(target); } if out.status.success() { return Ok(target); }
tracing::warn!( tracing::warn!(
target: "pxeforge::smb", target: "openpxe::smb",
stderr=%String::from_utf8_lossy(&out.stderr), stderr=%String::from_utf8_lossy(&out.stderr),
"7z extract failed, trying bsdtar" "7z extract failed, trying bsdtar"
); );
@@ -271,10 +279,10 @@ pub fn extract_windows_iso(iso_path: &Path, smb_dir: &Path, slug: &str) -> std::
.arg(&target) .arg(&target)
.output()?; .output()?;
if out.status.success() { return Ok(target); } if out.status.success() { return Ok(target); }
return Err(std::io::Error::new( return Err(std::io::Error::other(format!(
std::io::ErrorKind::Other, "bsdtar failed: {}",
format!("bsdtar failed: {}", String::from_utf8_lossy(&out.stderr)), String::from_utf8_lossy(&out.stderr)
)); )));
} }
Err(std::io::Error::new( Err(std::io::Error::new(
std::io::ErrorKind::NotFound, std::io::ErrorKind::NotFound,
@@ -307,7 +315,7 @@ mod tests {
fn start_without_smbd_reports_missing() { fn start_without_smbd_reports_missing() {
// Drop smbd from PATH for this test. // Drop smbd from PATH for this test.
let saved = std::env::var_os("PATH"); let saved = std::env::var_os("PATH");
std::env::set_var("PATH", "/usr/nowhere-pxeforge-test"); std::env::set_var("PATH", "/usr/nowhere-openpxe-test");
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let m = SmbManager::new(dir.path().into()); let m = SmbManager::new(dir.path().into());
let st = m.start(); let st = m.start();
+13 -13
View File
@@ -4,7 +4,7 @@ use crate::entry::{BootEntry, BootKind, KernelArgs};
use crate::introspect::{introspect, DistroFamily, IntrospectionReport}; use crate::introspect::{introspect, DistroFamily, IntrospectionReport};
use bytes::Bytes; use bytes::Bytes;
use parking_lot::RwLock; use parking_lot::RwLock;
use pxeforge_core::{Error, Result}; use openpxe_core::{Error, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::collections::HashMap; use std::collections::HashMap;
@@ -19,9 +19,10 @@ use tokio::io::AsyncWriteExt;
/// `Nfs` entries point at a file inside a remote share that the /// `Nfs` entries point at a file inside a remote share that the
/// `NfsManager` is keeping mounted. We resolve the on-disk path lazily /// `NfsManager` is keeping mounted. We resolve the on-disk path lazily
/// in [`IsoStore::iso_path_for`] using the `nfs_root` set at startup. /// in [`IsoStore::iso_path_for`] using the `nfs_root` set at startup.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum IsoSource { pub enum IsoSource {
#[default]
Local, Local,
Nfs { Nfs {
mount_id: String, mount_id: String,
@@ -30,12 +31,6 @@ pub enum IsoSource {
}, },
} }
impl Default for IsoSource {
fn default() -> Self {
Self::Local
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IsoMeta { pub struct IsoMeta {
/// Stable slug used in URLs (derived from the uploaded filename). /// Stable slug used in URLs (derived from the uploaded filename).
@@ -156,7 +151,11 @@ impl IsoStore {
while let Some(e) = entries.next_entry().await? { while let Some(e) = entries.next_entry().await? {
let p = e.path(); let p = e.path();
if p.extension().and_then(|s| s.to_str()) != Some("json") { continue; } if p.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
if !p.file_name().and_then(|s| s.to_str()).map_or(false, |n| n.ends_with(".meta.json")) { if !p
.file_name()
.and_then(|s| s.to_str())
.is_some_and(|n| n.ends_with(".meta.json"))
{
continue; continue;
} }
if let Ok(text) = tokio::fs::read_to_string(&p).await { if let Ok(text) = tokio::fs::read_to_string(&p).await {
@@ -218,7 +217,8 @@ impl IsoStore {
pub fn list(&self) -> Vec<IsoMeta> { pub fn list(&self) -> Vec<IsoMeta> {
let g = self.inner.read(); let g = self.inner.read();
let mut v: Vec<_> = g.isos.values().cloned().collect(); let mut v: Vec<_> = g.isos.values().cloned().collect();
v.sort_by(|a, b| b.uploaded_at.cmp(&a.uploaded_at)); // Newest-first by upload time.
v.sort_by_key(|m| std::cmp::Reverse(m.uploaded_at));
v v
} }
@@ -403,9 +403,9 @@ fn linux_cmdline(family: DistroFamily, id: &str) -> String {
DistroFamily::OpenSuse => format!( DistroFamily::OpenSuse => format!(
"install={iso_url} netsetup=dhcp" "install={iso_url} netsetup=dhcp"
), ),
DistroFamily::Arch => format!( DistroFamily::Arch => {
"archiso_http_srv=${{base-url}}/iso/ archisobasedir=arch ip=dhcp copytoram" "archiso_http_srv=${base-url}/iso/ archisobasedir=arch ip=dhcp copytoram".to_string()
), }
DistroFamily::Alpine => format!( DistroFamily::Alpine => format!(
"alpine_repo=${{base-url}}/iso/{id}/ modloop=${{base-url}}/iso/{id}/boot/modloop-lts ip=dhcp" "alpine_repo=${{base-url}}/iso/{id}/ modloop=${{base-url}}/iso/{id}/boot/modloop-lts ip=dhcp"
), ),
+23 -6
View File
@@ -152,18 +152,35 @@ const WINPESHL_INI: &str = "[LaunchApps]\r\n\
/// Uses CRLF line endings because WinPE cmd.exe requires them for .cmd files /// Uses CRLF line endings because WinPE cmd.exe requires them for .cmd files
/// created on unix hosts. /// created on unix hosts.
fn render_startnet(host: &str, share: &str) -> String { fn render_startnet(host: &str, share: &str) -> String {
let mut s = String::new(); use std::fmt::Write as _;
let host = host.trim(); let host = host.trim();
let share = share.trim_matches('/'); let share = share.trim_matches('/');
let mut s = String::new();
// Windows-style CRLF; consumed verbatim by cmd.exe inside WinPE.
// 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 off\r\n");
s.push_str("echo PXEForge WinPE bootstrap\r\n"); s.push_str("echo OpenPXE WinPE bootstrap\r\n");
s.push_str("wpeinit\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.
s.push_str("net start Workstation >nul 2>&1\r\n");
s.push_str("ipconfig /renew\r\n"); s.push_str("ipconfig /renew\r\n");
s.push_str(&format!("echo Waiting for SMB server {host} to be reachable...\r\n")); writeln!(s, "echo Waiting for SMB server {host} to be reachable...\r").unwrap();
s.push_str(&format!(":waitsmb\r\nping -n 1 -w 500 {host} >nul && goto havenet\r\ntimeout /t 2 /nobreak >nul\r\ngoto waitsmb\r\n")); writeln!(
s,
":waitsmb\r\nping -n 1 -w 500 {host} >nul && goto havenet\r\n\
timeout /t 2 /nobreak >nul\r\ngoto waitsmb\r"
)
.unwrap();
s.push_str(":havenet\r\n"); s.push_str(":havenet\r\n");
s.push_str(&format!("echo Mapping install media from \\\\{host}\\{share}...\r\n")); writeln!(s, "echo Mapping install media from \\\\{host}\\{share}...\r").unwrap();
s.push_str(&format!(":mapshare\r\nnet use Z: \\\\{host}\\{share} /user:guest \"\" /persistent:no && goto mapped\r\ntimeout /t 3 /nobreak >nul\r\ngoto mapshare\r\n")); writeln!(
s,
":mapshare\r\nnet use Z: \\\\{host}\\{share} /user:guest \"\" /persistent:no && goto mapped\r\n\
timeout /t 3 /nobreak >nul\r\ngoto mapshare\r"
)
.unwrap();
s.push_str(":mapped\r\n"); s.push_str(":mapped\r\n");
s.push_str("echo Starting Windows Setup\r\n"); s.push_str("echo Starting Windows Setup\r\n");
s.push_str("Z:\\setup.exe\r\n"); s.push_str("Z:\\setup.exe\r\n");
@@ -1,5 +1,5 @@
[package] [package]
name = "pxeforge" name = "openpxe"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
@@ -10,16 +10,16 @@ description = "Container-native PXE boot server — a lightweight Rust clone of
workspace = true workspace = true
[[bin]] [[bin]]
name = "pxeforge" name = "openpxe"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
pxeforge-core.workspace = true openpxe-core.workspace = true
pxeforge-dhcp-proxy.workspace = true openpxe-dhcp-proxy.workspace = true
pxeforge-tftp.workspace = true openpxe-tftp.workspace = true
pxeforge-http-api.workspace = true openpxe-http-api.workspace = true
pxeforge-iso-store.workspace = true openpxe-iso-store.workspace = true
pxeforge-ipxe-assets.workspace = true openpxe-ipxe-assets.workspace = true
tokio.workspace = true tokio.workspace = true
axum.workspace = true axum.workspace = true
tracing.workspace = true tracing.workspace = true
@@ -1,24 +1,27 @@
//! PXEForge entry point. Wires the three protocol servers (DHCP proxy, //! OpenPXE entry point. Wires the three protocol servers (DHCP proxy,
//! TFTP, HTTP) to the shared ISO store and client registry, then runs //! TFTP, HTTP) to the shared ISO store and client registry, then runs
//! them concurrently. //! them concurrently.
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use pxeforge_core::{ClientRegistry, Config, DhcpMode, GateQueue, LogBus, LogBusLayer, SettingsStore}; use openpxe_core::{
use pxeforge_dhcp_proxy::DhcpProxyServer; ClientRegistry, Config, DhcpMode, DeploymentQueue, HostBindings, LogBus, LogBusLayer, Metrics,
use pxeforge_http_api::{build_router, AppState}; SettingsStore,
use pxeforge_iso_store::{IsoStore, NfsManager, SmbManager}; };
use openpxe_dhcp_proxy::DhcpProxyServer;
use openpxe_http_api::{build_router, AppState};
use openpxe_iso_store::{IsoStore, NfsManager, SmbManager};
use std::sync::Arc; use std::sync::Arc;
use pxeforge_tftp::TftpServer; use openpxe_tftp::TftpServer;
use std::net::{Ipv4Addr, SocketAddr}; use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf; use std::path::PathBuf;
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
#[command(name = "pxeforge", about = "Container-native PXE boot server", version)] #[command(name = "openpxe", about = "Container-native PXE boot server", version)]
struct Cli { struct Cli {
/// Path to a TOML config file. All fields have sensible defaults and can /// Path to a TOML config file. All fields have sensible defaults and can
/// also be overridden with env vars (PXEFORGE_*). /// also be overridden with env vars (OPENPXE_*).
#[arg(long, env = "PXEFORGE_CONFIG")] #[arg(long, env = "OPENPXE_CONFIG")]
config: Option<PathBuf>, config: Option<PathBuf>,
#[command(subcommand)] #[command(subcommand)]
@@ -35,8 +38,8 @@ enum Command {
/// Example: /// Example:
/// docker run --rm \ /// docker run --rm \
/// -v /my/isos:/seed:ro \ /// -v /my/isos:/seed:ro \
/// -v pxeforge-data:/var/lib/pxeforge/isos \ /// -v openpxe-data:/var/lib/openpxe/isos \
/// pxeforge:0.1.0 seed --from /seed /// openpxe:0.1.0 seed --from /seed
Seed { Seed {
/// Source directory containing one or more `.iso` files. /// Source directory containing one or more `.iso` files.
#[arg(long)] #[arg(long)]
@@ -67,7 +70,7 @@ async fn main() -> anyhow::Result<()> {
return run_command(cmd, config).await; return run_command(cmd, config).await;
} }
pxeforge_ipxe_assets::log_availability(); openpxe_ipxe_assets::log_availability();
let our_ip = match config.server.public_ip { let our_ip = match config.server.public_ip {
Some(ip) => { Some(ip) => {
@@ -85,7 +88,7 @@ async fn main() -> anyhow::Result<()> {
// message instead of serving a broken deployment. // message instead of serving a broken deployment.
anyhow::bail!( anyhow::bail!(
"could not detect a non-loopback IPv4 address for this host. \ "could not detect a non-loopback IPv4 address for this host. \
Set PXEFORGE_PUBLIC_IP=<your-ip> (e.g. `-e PXEFORGE_PUBLIC_IP=10.0.0.5` \ Set OPENPXE_PUBLIC_IP=<your-ip> (e.g. `-e OPENPXE_PUBLIC_IP=10.0.0.5` \
in docker, or the env block in OpenShift Deployment) to advertise \ in docker, or the env block in OpenShift Deployment) to advertise \
a specific IP to PXE clients." a specific IP to PXE clients."
); );
@@ -97,8 +100,10 @@ async fn main() -> anyhow::Result<()> {
let iso_store = IsoStore::new(config.paths.iso_dir.clone()); let iso_store = IsoStore::new(config.paths.iso_dir.clone());
iso_store.load_from_disk().await?; iso_store.load_from_disk().await?;
let clients = ClientRegistry::new(); let clients = ClientRegistry::new();
let gates = GateQueue::new(); let gates = DeploymentQueue::new();
let settings = SettingsStore::load_or_default(&config.paths.work_dir); let settings = SettingsStore::load_or_default(&config.paths.work_dir);
let hosts = HostBindings::load_or_default(&config.paths.work_dir);
let metrics = Metrics::new();
// Build the SMB manager unconditionally — it starts/stops on the // Build the SMB manager unconditionally — it starts/stops on the
// Windows toggle, not at process start. If the `smb_dir` isn't // Windows toggle, not at process start. If the `smb_dir` isn't
@@ -115,7 +120,7 @@ async fn main() -> anyhow::Result<()> {
let nfs = NfsManager::new(&config.paths.work_dir, iso_store.clone()); let nfs = NfsManager::new(&config.paths.work_dir, iso_store.clone());
iso_store.set_nfs_root(nfs.mount_root()); iso_store.set_nfs_root(nfs.mount_root());
if let Err(e) = nfs.load_and_remount().await { if let Err(e) = nfs.load_and_remount().await {
tracing::warn!(target: "pxeforge::nfs", "could not reload NFS mounts: {e}"); tracing::warn!(target: "openpxe::nfs", "could not reload NFS mounts: {e}");
} }
// Sniff network details for the Network tab. None of these are // Sniff network details for the Network tab. None of these are
@@ -124,7 +129,7 @@ async fn main() -> anyhow::Result<()> {
// own gateway. // own gateway.
let net = detect_network_info(our_ip); let net = detect_network_info(our_ip);
tracing::info!( tracing::info!(
target: "pxeforge::net", target: "openpxe::net",
nic = %net.nic_name, mask = %net.subnet_mask, gateway = %net.gateway, nic = %net.nic_name, mask = %net.subnet_mask, gateway = %net.gateway,
"network info" "network info"
); );
@@ -133,7 +138,9 @@ async fn main() -> anyhow::Result<()> {
iso_store: iso_store.clone(), iso_store: iso_store.clone(),
clients: clients.clone(), clients: clients.clone(),
settings: settings.clone(), settings: settings.clone(),
gates: gates.clone(), queue: gates.clone(),
hosts: hosts.clone(),
metrics: metrics.clone(),
smb: Some(smb.clone()), smb: Some(smb.clone()),
nfs: nfs.clone(), nfs: nfs.clone(),
log_bus: log_bus.clone(), log_bus: log_bus.clone(),
@@ -148,12 +155,17 @@ async fn main() -> anyhow::Result<()> {
let router = build_router(state); let router = build_router(state);
let http_task = tokio::spawn(async move { let http_task = tokio::spawn(async move {
let listener = tokio::net::TcpListener::bind(http_addr).await?; let listener = tokio::net::TcpListener::bind(http_addr).await?;
tracing::info!(target: "pxeforge::http", "HTTP listening on {http_addr}"); tracing::info!(target: "openpxe::http", "HTTP listening on {http_addr}");
axum::serve(listener, router).await?; axum::serve(listener, router).await?;
Ok::<_, anyhow::Error>(()) Ok::<_, anyhow::Error>(())
}); });
let tftp = TftpServer::new(config.server.tftp_bind, config.server.tftp_port, clients.clone()); let tftp = TftpServer::new(
config.server.tftp_bind,
config.server.tftp_port,
clients.clone(),
metrics.clone(),
);
let tftp_task = tokio::spawn(tftp.run()); let tftp_task = tokio::spawn(tftp.run());
let dhcp_task: tokio::task::JoinHandle<anyhow::Result<()>> = match config.network.dhcp_mode { let dhcp_task: tokio::task::JoinHandle<anyhow::Result<()>> = match config.network.dhcp_mode {
@@ -165,11 +177,12 @@ async fn main() -> anyhow::Result<()> {
our_ip, our_ip,
public_base_url.clone(), public_base_url.clone(),
clients.clone(), clients.clone(),
metrics.clone(),
); );
tokio::spawn(s.run()) tokio::spawn(s.run())
} }
DhcpMode::Disabled => { DhcpMode::Disabled => {
tracing::info!(target: "pxeforge::dhcp", "DHCP disabled — external DHCP must set next-server + filename"); tracing::info!(target: "openpxe::dhcp", "DHCP disabled — external DHCP must set next-server + filename");
tokio::spawn(async { futures_forever().await }) tokio::spawn(async { futures_forever().await })
} }
}; };
@@ -218,7 +231,7 @@ async fn seed_from_dir(src: &std::path::Path, config: &Config, dry_run: bool) ->
let mut handle = match store.begin_upload(&filename).await { let mut handle = match store.begin_upload(&filename).await {
Ok(h) => h, Ok(h) => h,
Err(pxeforge_core::Error::Invalid(e)) => { Err(openpxe_core::Error::Invalid(e)) => {
eprintln!(" skip: {e}"); eprintln!(" skip: {e}");
skipped += 1; skipped += 1;
continue; continue;
@@ -245,7 +258,7 @@ async fn seed_from_dir(src: &std::path::Path, config: &Config, dry_run: bool) ->
/// detection fails — callers should fail startup rather than silently using /// detection fails — callers should fail startup rather than silently using
/// a loopback address (which would give every PXE client an unreachable /// a loopback address (which would give every PXE client an unreachable
/// `http://127.0.0.1/...`). Users in multi-homed setups should set /// `http://127.0.0.1/...`). Users in multi-homed setups should set
/// `PXEFORGE_PUBLIC_IP` explicitly. /// `OPENPXE_PUBLIC_IP` explicitly.
fn detect_primary_ipv4() -> Option<Ipv4Addr> { fn detect_primary_ipv4() -> Option<Ipv4Addr> {
// First try: route to the public internet. `UdpSocket::connect` to a // First try: route to the public internet. `UdpSocket::connect` to a
// well-known external address causes the OS to populate `local_addr` // well-known external address causes the OS to populate `local_addr`
@@ -288,8 +301,8 @@ fn hostname() -> std::io::Result<String> {
fn init_tracing(bus: Arc<LogBus>) { fn init_tracing(bus: Arc<LogBus>) {
use tracing_subscriber::{fmt, prelude::*, EnvFilter}; use tracing_subscriber::{fmt, prelude::*, EnvFilter};
let filter = EnvFilter::try_from_env("PXEFORGE_LOG") let filter = EnvFilter::try_from_env("OPENPXE_LOG")
.unwrap_or_else(|_| EnvFilter::new("info,pxeforge=debug")); .unwrap_or_else(|_| EnvFilter::new("info,openpxe=debug"));
tracing_subscriber::registry() tracing_subscriber::registry()
.with(filter) .with(filter)
.with(fmt::layer().with_target(true)) .with(fmt::layer().with_target(true))
+3 -3
View File
@@ -1,5 +1,5 @@
[package] [package]
name = "pxeforge-tftp" name = "openpxe-tftp"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
@@ -10,8 +10,8 @@ description = "TFTP server (RFC 1350/2347/2348/2349/7440) for iPXE chainload"
workspace = true workspace = true
[dependencies] [dependencies]
pxeforge-core.workspace = true openpxe-core.workspace = true
pxeforge-ipxe-assets.workspace = true openpxe-ipxe-assets.workspace = true
tokio.workspace = true tokio.workspace = true
socket2.workspace = true socket2.workspace = true
tracing.workspace = true tracing.workspace = true
+39 -22
View File
@@ -7,12 +7,12 @@
//! `tftpd`/`in.tftpd` works and is why TFTP is awkward behind stateful NAT: //! `tftpd`/`in.tftpd` works and is why TFTP is awkward behind stateful NAT:
//! the ephemeral ports must be reachable from the client. //! the ephemeral ports must be reachable from the client.
//! //!
//! We only serve files from `pxeforge_ipxe_assets::asset_bytes` — that is, //! We only serve files from `openpxe_ipxe_assets::asset_bytes` — that is,
//! the bundled iPXE binaries and wimboot. No filesystem is ever opened, so //! the bundled iPXE binaries and wimboot. No filesystem is ever opened, so
//! `../` path traversal attempts simply return ENOENT. //! `../` path traversal attempts simply return ENOENT.
use pxeforge_core::{ClientEvent, ClientRegistry}; use openpxe_core::{ClientEvent, ClientRegistry};
use pxeforge_ipxe_assets::asset_bytes; use openpxe_ipxe_assets::asset_bytes;
use socket2::{Domain, Protocol, Socket, Type}; use socket2::{Domain, Protocol, Socket, Type};
use std::net::{IpAddr, SocketAddr}; use std::net::{IpAddr, SocketAddr};
use std::sync::Arc; use std::sync::Arc;
@@ -35,32 +35,41 @@ pub struct TftpServer {
bind: IpAddr, bind: IpAddr,
port: u16, port: u16,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
} }
impl TftpServer { impl TftpServer {
pub fn new(bind: IpAddr, port: u16, clients: Arc<ClientRegistry>) -> Self { pub fn new(
Self { bind, port, clients } bind: IpAddr,
port: u16,
clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
) -> Self {
Self { bind, port, clients, metrics }
} }
pub async fn run(self) -> anyhow::Result<()> { pub async fn run(self) -> anyhow::Result<()> {
let sock = bind_udp(self.bind, self.port)?; let sock = bind_udp(self.bind, self.port)?;
tracing::info!(target: "pxeforge::tftp", "TFTP listening on {}:{}", self.bind, self.port); tracing::info!(target: "openpxe::tftp", "TFTP listening on {}:{}", self.bind, self.port);
let clients = self.clients.clone(); let clients = self.clients.clone();
let metrics = self.metrics.clone();
let mut buf = vec![0u8; 2048]; let mut buf = vec![0u8; 2048];
loop { loop {
let (n, from) = match sock.recv_from(&mut buf).await { let (n, from) = match sock.recv_from(&mut buf).await {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
tracing::warn!(target: "pxeforge::tftp", "recv error: {e}"); tracing::warn!(target: "openpxe::tftp", "recv error: {e}");
continue; continue;
} }
}; };
let data = buf[..n].to_vec(); let data = buf[..n].to_vec();
let clients = clients.clone(); let clients = clients.clone();
let metrics = metrics.clone();
let bind_ip = self.bind; let bind_ip = self.bind;
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = handle_rrq(data, from, bind_ip, clients).await { if let Err(e) = handle_rrq(data, from, bind_ip, clients, metrics.clone()).await {
tracing::warn!(target: "pxeforge::tftp", peer=%from, "handler error: {e}"); metrics.record_tftp_err();
tracing::warn!(target: "openpxe::tftp", peer=%from, "handler error: {e}");
} }
}); });
} }
@@ -72,10 +81,10 @@ async fn handle_rrq(
peer: SocketAddr, peer: SocketAddr,
bind_ip: IpAddr, bind_ip: IpAddr,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let req = match parse_rrq(&packet) { let Some(req) = parse_rrq(&packet) else {
Some(r) => r, return Ok(());
None => return Ok(()),
}; };
let Request { filename, options, .. } = req; let Request { filename, options, .. } = req;
@@ -84,7 +93,7 @@ async fn handle_rrq(
let Some(file_bytes) = asset_bytes(&filename) else { let Some(file_bytes) = asset_bytes(&filename) else {
let _ = send_error(&sock, peer, ERR_FILE_NOT_FOUND, "no such file").await; let _ = send_error(&sock, peer, ERR_FILE_NOT_FOUND, "no such file").await;
tracing::info!(target: "pxeforge::tftp", peer=%peer, file=%filename, "404"); tracing::info!(target: "openpxe::tftp", peer=%peer, file=%filename, "404");
clients.record( clients.record(
&peer.ip().to_string(), &peer.ip().to_string(),
Some(peer.ip()), Some(peer.ip()),
@@ -95,7 +104,7 @@ async fn handle_rrq(
}; };
tracing::info!( tracing::info!(
target: "pxeforge::tftp", target: "openpxe::tftp",
peer=%peer, file=%filename, size=file_bytes.len(), peer=%peer, file=%filename, size=file_bytes.len(),
"serving" "serving"
); );
@@ -149,7 +158,11 @@ async fn handle_rrq(
let total = file_bytes.len(); let total = file_bytes.len();
let mut offset: usize = 0; let mut offset: usize = 0;
let mut block_no: u16 = 1; let mut block_no: u16 = 1;
let mut needs_zero_final = false; // spec: if last data block == blksize, follow with empty DATA // Per RFC 1350: if the final data block is exactly blksize, the
// server must follow up with a zero-length DATA so the client knows
// the transfer has ended. The flag is set inside the loop and
// tested at end-of-transfer.
let needs_zero_final;
'transfer: loop { 'transfer: loop {
let window_start_offset = offset; let window_start_offset = offset;
@@ -181,13 +194,13 @@ async fn handle_rrq(
loop { loop {
match tokio::time::timeout(Duration::from_secs(3), recv_ack(&sock, peer)).await { match tokio::time::timeout(Duration::from_secs(3), recv_ack(&sock, peer)).await {
Ok(Ok(acked)) if acked == last_block_in_window => break, Ok(Ok(acked)) if acked == last_block_in_window => break,
Ok(Ok(_)) => continue, // stale ACK from an earlier block — ignore Ok(Ok(_)) => {} // stale ACK from an earlier block — ignore
Ok(Err(e)) => return Err(e), Ok(Err(e)) => return Err(e),
Err(_) => { Err(_) => {
tries += 1; tries += 1;
if tries > 5 { if tries > 5 {
tracing::warn!( tracing::warn!(
target: "pxeforge::tftp", target: "openpxe::tftp",
peer=%peer, last_block=last_block_in_window, peer=%peer, last_block=last_block_in_window,
"timeout after {tries} retries, aborting transfer" "timeout after {tries} retries, aborting transfer"
); );
@@ -228,7 +241,8 @@ async fn handle_rrq(
let _ = tokio::time::timeout(Duration::from_secs(3), recv_ack(&sock, peer)).await; let _ = tokio::time::timeout(Duration::from_secs(3), recv_ack(&sock, peer)).await;
} }
tracing::debug!(target: "pxeforge::tftp", peer=%peer, bytes=total, "transfer complete"); tracing::debug!(target: "openpxe::tftp", peer=%peer, bytes=total, "transfer complete");
metrics.record_tftp_ok(total as u64);
Ok(()) Ok(())
} }
@@ -249,7 +263,7 @@ fn parse_rrq(pkt: &[u8]) -> Option<Request> {
let mode = read_cstr(&mut rest)?; let mode = read_cstr(&mut rest)?;
let mut options = Vec::new(); let mut options = Vec::new();
while !rest.is_empty() { while !rest.is_empty() {
let k = match read_cstr(&mut rest) { Some(s) => s, None => break }; let Some(k) = read_cstr(&mut rest) else { break };
if k.is_empty() { break; } if k.is_empty() { break; }
let v = read_cstr(&mut rest).unwrap_or_default(); let v = read_cstr(&mut rest).unwrap_or_default();
options.push((k.to_ascii_lowercase(), v)); options.push((k.to_ascii_lowercase(), v));
@@ -311,7 +325,7 @@ async fn recv_ack(sock: &UdpSocket, peer: SocketAddr) -> anyhow::Result<u16> {
let code = u16::from_be_bytes([buf[2], buf[3]]); let code = u16::from_be_bytes([buf[2], buf[3]]);
anyhow::bail!("client error {code}"); anyhow::bail!("client error {code}");
} }
_ => continue, _ => {}
} }
} }
} }
@@ -327,7 +341,7 @@ async fn wait_for_ack(
sock.send_to(to_retx, peer).await?; sock.send_to(to_retx, peer).await?;
match tokio::time::timeout(Duration::from_secs(3), recv_ack(sock, peer)).await { match tokio::time::timeout(Duration::from_secs(3), recv_ack(sock, peer)).await {
Ok(Ok(b)) if b == expect_block => return Ok(true), Ok(Ok(b)) if b == expect_block => return Ok(true),
Ok(Ok(_)) => continue, Ok(Ok(_)) => {}
Ok(Err(_)) | Err(_) => { Ok(Err(_)) | Err(_) => {
tries += 1; tries += 1;
if tries > 5 { return Ok(false); } if tries > 5 { return Ok(false); }
@@ -385,7 +399,10 @@ mod tests {
fn parses_rrq_with_options() { fn parses_rrq_with_options() {
// RRQ "snponly.efi" mode "octet" blksize=1468 tsize=0 // RRQ "snponly.efi" mode "octet" blksize=1468 tsize=0
let mut pkt = vec![0, OP_RRQ as u8]; let mut pkt = vec![0, OP_RRQ as u8];
pkt.extend_from_slice(b"snponly.efi\0octet\0blksize\01468\0tsize\00\0"); // The single-digit `\0` escapes here are NUL terminators between
// TFTP option name/value pairs — using `\x00` to dodge clippy's
// "octal-looking escape" lint.
pkt.extend_from_slice(b"snponly.efi\x00octet\x00blksize\x001468\x00tsize\x000\x00");
let r = parse_rrq(&pkt).unwrap(); let r = parse_rrq(&pkt).unwrap();
assert_eq!(r.filename, "snponly.efi"); assert_eq!(r.filename, "snponly.efi");
assert_eq!(r.mode, "octet"); assert_eq!(r.mode, "octet");
+2 -2
View File
@@ -1,10 +1,10 @@
[package] [package]
name = "pxeforge-webui" name = "openpxe-webui"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
description = "Embedded single-file web UI for PXEForge" description = "Embedded single-file web UI for OpenPXE"
[lints] [lints]
workspace = true workspace = true
+177 -83
View File
@@ -1,35 +1,66 @@
/* PXEForge web UI — Netbox-style layout, fully offline. /* OpenPXE web UI — Netbox-style minimal layout, fully offline.
* Design tokens are CSS variables so a later phase can re-theme without *
* touching markup or JS. */ * Theme tokens live on `:root` (dark default) and `:root[data-theme=light]`.
* Both palettes share variable *names*, so component CSS uses
* `var(--bg)` regardless and the toggle in the topbar just flips the
* data-attribute. No JS-side recolouring, no React re-renders, no FOUC
* (the inline script in index.html paints the right theme before main
* CSS lands). */
:root { :root {
--bg: #0b1018; /* Dark palette (default). */
--bg-panel: #121826; --bg: #0b1018;
--bg-panel-2: #1a2334; --bg-panel: #121826;
--bg-elev: #223047; --bg-panel-2: #1a2334;
--fg: #e4e8ef; --bg-elev: #223047;
--fg-dim: #8a94a7; --fg: #e4e8ef;
--fg-dimmer: #5a6379; --fg-dim: #8a94a7;
--accent: #00d4b4; /* Netbox-ish teal */ --fg-dimmer: #5a6379;
--accent-dim: #07a38c; --accent: #00d4b4; /* Netbox-ish teal */
--warn: #ffb347; --accent-dim: #07a38c;
--err: #ef6e6e; --warn: #ffb347;
--ok: #4ade80; --err: #ef6e6e;
--border: #223047; --ok: #4ade80;
--border: #223047;
--border-soft: #172033; --border-soft: #172033;
--radius: 6px; --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; --radius-lg: 10px;
--sidebar-w: 240px; --sidebar-w: 240px;
--topbar-h: 54px; --topbar-h: 56px;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
} }
:root[data-theme="light"] {
/* Light palette — high-contrast neutral, accent unchanged for brand
consistency. Designed against Netbox Labs's reference screenshot:
near-white surfaces, soft grey dividers, dark text. */
--bg: #f6f8fb;
--bg-panel: #ffffff;
--bg-panel-2: #f0f3f8;
--bg-elev: #e6ebf2;
--fg: #1c2330;
--fg-dim: #5a6377;
--fg-dimmer: #95a0b3;
--accent: #00b89c;
--accent-dim: #008b73;
--warn: #b67016;
--err: #c63a3a;
--ok: #1f9b54;
--border: #d8dde6;
--border-soft: #e7eaf0;
--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);
}
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { height: 100%; } html, body { height: 100%; }
body { body {
margin: 0; font-family: var(--sans); font-size: 14px; line-height: 1.5; margin: 0; font-family: var(--sans); font-size: 14px; line-height: 1.5;
background: var(--bg); color: var(--fg); background: var(--bg); color: var(--fg);
transition: background 0.16s ease, color 0.16s ease;
} }
a { color: var(--accent); text-decoration: none; } a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
@@ -62,15 +93,11 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
.sidebar .brand strong { font-size: 16px; letter-spacing: 0.4px; } .sidebar .brand strong { font-size: 16px; letter-spacing: 0.4px; }
.sidebar .brand .sub { color: var(--fg-dim); font-size: 11px; } .sidebar .brand .sub { color: var(--fg-dim); font-size: 11px; }
.sidebar nav { padding: 10px 0; flex: 1; overflow-y: auto; } .sidebar nav { padding: 10px 0; flex: 1; overflow-y: auto; }
.sidebar nav .group {
padding: 10px 18px 6px;
font-size: 10.5px; color: var(--fg-dimmer); text-transform: uppercase;
letter-spacing: 1px;
}
.sidebar nav a { .sidebar nav a {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
padding: 7px 18px; color: var(--fg); font-size: 13.5px; padding: 8px 18px; color: var(--fg); font-size: 13.5px;
border-left: 2px solid transparent; border-left: 2px solid transparent;
cursor: pointer;
} }
.sidebar nav a:hover { background: var(--bg-panel-2); text-decoration: none; } .sidebar nav a:hover { background: var(--bg-panel-2); text-decoration: none; }
.sidebar nav a.active { .sidebar nav a.active {
@@ -96,7 +123,7 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
.topbar { .topbar {
grid-area: topbar; grid-area: topbar;
display: flex; align-items: center; display: flex; align-items: center;
padding: 0 20px; gap: 18px; padding: 0 20px; gap: 14px;
background: var(--bg-panel); background: var(--bg-panel);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
@@ -104,23 +131,31 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
margin: 0; font-size: 15px; font-weight: 600; margin: 0; font-size: 15px; font-weight: 600;
color: var(--fg); letter-spacing: 0.2px; color: var(--fg); letter-spacing: 0.2px;
} }
.topbar .tabs { display: flex; gap: 4px; margin-left: 24px; }
.topbar .tabs button {
background: transparent; border: 0;
color: var(--fg-dim); font: inherit;
padding: 10px 14px; cursor: pointer;
border-bottom: 2px solid transparent;
}
.topbar .tabs button:hover { color: var(--fg); }
.topbar .tabs button.active { color: var(--accent); border-bottom-color: var(--accent); }
.topbar .spacer { flex: 1; } .topbar .spacer { flex: 1; }
.topbar .chip { .topbar .chip {
background: var(--bg-panel-2); border: 1px solid var(--border); background: var(--bg-panel-2); border: 1px solid var(--border);
color: var(--fg-dim); font-size: 12px; color: var(--fg-dim); font-size: 12px;
padding: 4px 10px; border-radius: 12px; padding: 4px 10px; border-radius: 12px;
white-space: nowrap;
} }
.topbar .chip strong { color: var(--fg); font-weight: 600; } .topbar .chip strong { color: var(--fg); font-weight: 600; }
/* Theme toggle button. Two glyphs stacked; CSS swaps which is visible
based on the active theme. Keeps the layout stable when toggling. */
.theme-toggle {
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;
}
.theme-toggle:hover { background: var(--bg-panel-2); border-color: var(--accent); }
.theme-toggle .t-sun { display: none; }
.theme-toggle .t-moon { display: inline; }
:root[data-theme="light"] .theme-toggle .t-sun { display: inline; }
:root[data-theme="light"] .theme-toggle .t-moon { display: none; }
/* ── Main content ─────────────────────────────────────────────────── */ /* ── Main content ─────────────────────────────────────────────────── */
.main { .main {
@@ -142,6 +177,7 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
overflow: hidden; overflow: hidden;
box-shadow: var(--shadow-card);
} }
.card > header { .card > header {
padding: 12px 16px; padding: 12px 16px;
@@ -153,9 +189,7 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
.card > header .sub { color: var(--fg-dim); font-size: 12px; margin-left: auto; } .card > header .sub { color: var(--fg-dim); font-size: 12px; margin-left: auto; }
.card .body { padding: 16px; } .card .body { padding: 16px; }
.stat { .stat { padding: 16px; }
padding: 16px;
}
.stat .label { color: var(--fg-dim); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.8px; } .stat .label { color: var(--fg-dim); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.8px; }
.stat .value { font-size: 28px; font-weight: 600; line-height: 1.1; margin-top: 4px; color: var(--fg); } .stat .value { font-size: 28px; font-weight: 600; line-height: 1.1; margin-top: 4px; color: var(--fg); }
.stat .trend { font-size: 12px; color: var(--fg-dim); margin-top: 4px; } .stat .trend { font-size: 12px; color: var(--fg-dim); margin-top: 4px; }
@@ -179,12 +213,12 @@ td.num { text-align: right; font-variant-numeric: tabular-nums; }
display: inline-block; display: inline-block;
padding: 2px 8px; border-radius: 10px; padding: 2px 8px; border-radius: 10px;
font-size: 11px; font-weight: 600; font-size: 11px; font-weight: 600;
background: #1b3148; color: #a2c5e8; background: var(--bg-elev); color: var(--fg-dim);
} }
.tag.ok { background: #103428; color: var(--ok); } .tag.ok { background: color-mix(in srgb, var(--ok) 22%, transparent); color: var(--ok); }
.tag.warn { background: #3a2a10; color: var(--warn); } .tag.warn { background: color-mix(in srgb, var(--warn) 22%, transparent); color: var(--warn); }
.tag.err { background: #3a1515; color: var(--err); } .tag.err { background: color-mix(in srgb, var(--err) 22%, transparent); color: var(--err); }
.tag.accent { background: #072f29; color: var(--accent); } .tag.accent { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--accent); }
.tag.arch { text-transform: uppercase; } .tag.arch { text-transform: uppercase; }
/* ── Forms ────────────────────────────────────────────────────────── */ /* ── Forms ────────────────────────────────────────────────────────── */
@@ -194,12 +228,13 @@ button, .btn {
border: 0; border-radius: var(--radius); border: 0; border-radius: var(--radius);
padding: 7px 14px; font: inherit; font-weight: 600; padding: 7px 14px; font: inherit; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background 0.12s ease;
} }
button:hover, .btn:hover { background: var(--accent-dim); color: #fff; } button:hover, .btn:hover { background: var(--accent-dim); color: #fff; }
button.ghost { background: transparent; color: var(--fg); border: 1px solid var(--border); } button.ghost { background: transparent; color: var(--fg); border: 1px solid var(--border); }
button.ghost:hover { background: var(--bg-panel-2); color: var(--fg); } button.ghost:hover { background: var(--bg-panel-2); color: var(--fg); }
button.danger { background: transparent; color: var(--err); border: 1px solid #4a1f1f; } button.danger { background: transparent; color: var(--err); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); }
button.danger:hover { background: #2a0b0b; color: var(--err); } button.danger:hover { background: color-mix(in srgb, var(--err) 14%, transparent); color: var(--err); }
label.field { label.field {
display: grid; gap: 4px; margin-bottom: 14px; display: grid; gap: 4px; margin-bottom: 14px;
@@ -238,38 +273,72 @@ label.check input { accent-color: var(--accent); }
background: var(--bg-panel-2); background: var(--bg-panel-2);
} }
.drop strong { color: var(--accent); } .drop strong { color: var(--accent); }
/* Plain progress bar (used for ISO uploads). */
.progress { height: 6px; background: var(--bg-panel-2); border-radius: 3px; overflow: hidden; margin-top: 12px; display: none; } .progress { height: 6px; background: var(--bg-panel-2); border-radius: 3px; overflow: hidden; margin-top: 12px; display: none; }
.progress.active { display: block; } .progress.active { display: block; }
.progress .bar { height: 100%; width: 0%; background: var(--accent); transition: width .25s; } .progress .bar { height: 100%; width: 0%; background: var(--accent); transition: width .25s; }
/* ── Gate queue "horse race" visual ───────────────────────────────── */ /* ── Imaging progress widget ───────────────────────────────────────
Animated brand mark paired with a horizontal progress bar; surfaces
.gate-track { on Dashboard and the Queue tab. Renamed from `.forge-progress` in
display: grid; gap: 6px; v0.3.0 — the anvil-themed naming is gone with the rebrand. */
padding: 10px 0; .queue-progress {
display: flex; align-items: center; gap: 16px;
padding: 16px;
} }
.gate-row { .queue-progress .mark {
display: grid; grid-template-columns: 32px 1fr auto auto; align-items: center; width: 56px; height: 56px; flex: none; border-radius: 50%;
gap: 14px; background: url("/assets/loader.svg") no-repeat center / contain;
padding: 8px 14px; filter: drop-shadow(0 0 16px rgba(255, 255, 255, 0.10));
background: var(--bg-panel-2); border-radius: var(--radius);
border-left: 3px solid var(--accent);
} }
.gate-row.assigned { border-left-color: var(--ok); } .queue-progress .info { flex: 1; min-width: 0; }
.gate-row .pos { font-family: var(--mono); font-size: 15px; color: var(--accent); font-weight: 600; } .queue-progress .info .label {
.gate-row.assigned .pos { color: var(--ok); } font-size: 12.5px; color: var(--fg-dim); margin-bottom: 6px;
.gate-row .mac { font-family: var(--mono); font-size: 13px; } }
.gate-row .meta { color: var(--fg-dim); font-size: 12px; } .queue-progress .bar-track {
height: 8px; background: var(--bg-elev); border-radius: 4px;
overflow: hidden; position: relative;
}
.queue-progress .bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-dim), var(--accent));
width: 0%;
transition: width 0.4s ease;
position: relative;
}
.queue-progress .bar-fill::after {
/* Subtle moving sheen so the bar feels alive even at 0% movement. */
content: ""; position: absolute; inset: 0;
background: linear-gradient(
90deg,
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;
}
@keyframes queue-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; }
.empty { color: var(--fg-dim); padding: 30px; text-align: center; } /* ── Page-load loader ────────────────────────────────────────────── */
.msg { color: var(--fg-dim); font-size: 12.5px; margin-top: 8px; } .loader {
.msg.err { color: var(--err); } display: flex; flex-direction: column; align-items: center; gap: 12px;
.msg.ok { color: var(--ok); } padding: 40px 20px;
color: var(--fg-dim);
}
.loader .mark {
width: 96px; height: 96px;
background: url("/assets/loader.svg") no-repeat center / contain;
}
/* ── Top bar readiness chip ──────────────────────────────────────── */ /* ── Top bar readiness chip ──────────────────────────────────────── */
.chip.ready { background: #103428; color: var(--ok); border-color: #1a4f3c; } .chip.ready { background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok); border-color: color-mix(in srgb, var(--ok) 35%, transparent); }
.chip.notready { background: #3a1515; color: var(--err); border-color: #5a1f1f; } .chip.notready { background: color-mix(in srgb, var(--err) 18%, transparent); color: var(--err); border-color: color-mix(in srgb, var(--err) 35%, transparent); }
.chip.warming { background: #3a2a10; color: var(--warn); border-color: #4a3a18; } .chip.warming { background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); border-color: color-mix(in srgb, var(--warn) 35%, transparent); }
/* ── Dashboard stat strip ────────────────────────────────────────── */ /* ── Dashboard stat strip ────────────────────────────────────────── */
.statstrip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; } .statstrip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
@@ -283,17 +352,18 @@ label.check input { accent-color: var(--accent); }
.kv .v.err { color: var(--err); } .kv .v.err { color: var(--err); }
.kv .v.ok { color: var(--ok); } .kv .v.ok { color: var(--ok); }
/* ── Image rows: amber tint on un-bootable images (Bootimus pattern) ── */ /* ── Image rows: amber tint on un-bootable images ────────────────── */
tr.unbootable td { background: rgba(255, 179, 71, 0.07) !important; } tr.unbootable td { background: color-mix(in srgb, var(--warn) 7%, transparent) !important; }
tr.unbootable td:first-child { border-left: 3px solid var(--warn); } tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
.row-warn { color: var(--warn); font-size: 11.5px; margin-top: 2px; } .row-warn { color: var(--warn); font-size: 11.5px; margin-top: 2px; }
/* ── Table source badge ──────────────────────────────────────────── */ /* ── Table source badge ──────────────────────────────────────────── */
.src-badge { font-family: var(--mono); font-size: 11px; padding: 1px 6px; .src-badge { font-family: var(--mono); font-size: 11px; padding: 1px 6px;
border-radius: 4px; background: var(--bg-elev); color: var(--fg-dim); } border-radius: 4px; background: var(--bg-elev); color: var(--fg-dim); }
.src-badge.nfs { background: #122a3a; color: #7cd3ff; } .src-badge.nfs { background: color-mix(in srgb, #7cd3ff 18%, var(--bg-elev));
color: color-mix(in srgb, #7cd3ff 90%, var(--fg)); }
/* ── NFS modal-ish add form ──────────────────────────────────────── */ /* ── NFS rows ────────────────────────────────────────────────────── */
.nfs-row { display: grid; grid-template-columns: 32px 1fr auto auto auto; align-items: center; .nfs-row { display: grid; grid-template-columns: 32px 1fr auto auto auto; align-items: center;
gap: 14px; padding: 10px 14px; background: var(--bg-panel-2); gap: 14px; padding: 10px 14px; background: var(--bg-panel-2);
border-left: 3px solid var(--accent); border-radius: var(--radius); } border-left: 3px solid var(--accent); border-radius: var(--radius); }
@@ -306,19 +376,43 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
.dot.err { background: var(--err); } .dot.err { background: var(--err); }
.dot.warn { background: var(--warn); } .dot.warn { background: var(--warn); }
/* ── Inline form rows (used by Network + NFS add) ───────────────── */ /* Inline form rows. */
.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; } .form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; }
@media (max-width: 900px) { .form-row { grid-template-columns: 1fr; } } @media (max-width: 900px) { .form-row { grid-template-columns: 1fr; } }
/* ── Gate queue "horse race" visual ──────────────────────────────── */
.queue-track {
display: grid; gap: 6px;
padding: 10px 0;
}
.queue-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; }
.empty { color: var(--fg-dim); padding: 30px; text-align: center; }
.msg { color: var(--fg-dim); font-size: 12.5px; margin-top: 8px; }
.msg.err { color: var(--err); }
.msg.ok { color: var(--ok); }
/* ── Terminal pane ──────────────────────────────────────────────── */ /* ── Terminal pane ──────────────────────────────────────────────── */
.terminal { .terminal {
display: flex; flex-direction: column; display: flex; flex-direction: column;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: #06090e; background: var(--terminal-bg);
overflow: hidden; overflow: hidden;
height: calc(100vh - var(--topbar-h) - 90px); height: calc(100vh - var(--topbar-h) - 90px);
min-height: 480px; min-height: 480px;
box-shadow: var(--shadow-card);
} }
.terminal .pane { .terminal .pane {
flex: 1; overflow: auto; flex: 1; overflow: auto;
@@ -330,37 +424,37 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
.terminal .pane .lvl-error { color: var(--err); } .terminal .pane .lvl-error { color: var(--err); }
.terminal .pane .lvl-warn { color: var(--warn); } .terminal .pane .lvl-warn { color: var(--warn); }
.terminal .pane .lvl-info { color: #cfd6e2; } .terminal .pane .lvl-info { color: #cfd6e2; }
.terminal .pane .lvl-debug { color: var(--fg-dim); } .terminal .pane .lvl-debug { color: #8b94a8; }
.terminal .pane .lvl-trace { color: var(--fg-dimmer); } .terminal .pane .lvl-trace { color: #5a6379; }
.terminal .pane .ts { color: var(--fg-dimmer); } .terminal .pane .ts { color: #5a6379; }
.terminal .pane .tg { color: #7cd3ff; } .terminal .pane .tg { color: #7cd3ff; }
.terminal .pane .echo { color: var(--accent); } .terminal .pane .echo { color: var(--accent); }
.terminal .input-row { .terminal .input-row {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
padding: 8px 14px; padding: 8px 14px;
background: #0a0e15; background: #0a0e15;
border-top: 1px solid var(--border); border-top: 1px solid #1d2330;
} }
.terminal .input-row .prompt { color: var(--accent); font-family: var(--mono); } .terminal .input-row .prompt { color: var(--accent); font-family: var(--mono); }
.terminal .input-row input { .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; font: inherit; font-family: var(--mono); font-size: 13px;
outline: none; padding: 4px 0; outline: none; padding: 4px 0;
} }
.terminal .toolbar { .terminal .toolbar {
display: flex; gap: 8px; align-items: center; display: flex; gap: 8px; align-items: center;
padding: 8px 14px; padding: 8px 14px;
background: var(--bg-panel-2); background: #0a0e15;
border-bottom: 1px solid var(--border); border-bottom: 1px solid #1d2330;
font-size: 12px; color: var(--fg-dim); font-size: 12px; color: #8a94a7;
} }
.terminal .toolbar .right { margin-left: auto; display: flex; gap: 6px; } .terminal .toolbar .right { margin-left: auto; display: flex; gap: 6px; }
.terminal .toolbar button { .terminal .toolbar button {
padding: 3px 9px; font-size: 11px; 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; font-weight: 500;
} }
.terminal .toolbar button:hover { color: var(--fg); background: var(--bg-elev); } .terminal .toolbar button:hover { color: #e4e8ef; background: #1d2330; }
/* ── About card ─────────────────────────────────────────────────── */ /* ── About card ─────────────────────────────────────────────────── */
.about-hero { padding: 20px 24px; } .about-hero { padding: 20px 24px; }
+187 -33
View File
@@ -1,7 +1,7 @@
// PXEForge web UI — vanilla JS, no build step, no framework, no network // OpenPXE web UI — vanilla JS, no build step, no framework, no network
// dependencies. Uses fetch() + EventSource only. // dependencies. Uses fetch() + EventSource only.
// //
// Tabs (Phase 4): Dashboard / Network / Forge Gate / Storage / Terminal / // Tabs (Phase 4): Dashboard / Network / Queue / Storage / Terminal /
// About. The shell swaps a single view into #view-root. // About. The shell swaps a single view into #view-root.
// //
// Keep this readable — nobody wants to debug a clever vanilla-JS // Keep this readable — nobody wants to debug a clever vanilla-JS
@@ -75,6 +75,26 @@
return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)}); return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
} }
// Animated brand-mark + progress bar widget. Built once here and inlined
// wherever a card wants to convey "an image is being deployed onto a
// queued client right now." Used on the Dashboard and the Queue tab.
function queueProgressWidget(imaging, queueTotal) {
const total = Math.max(queueTotal, imaging, 1);
const pct = imaging > 0 ? Math.round((imaging / total) * 100) : 0;
const root = el('div', {class: 'queue-progress' + (imaging === 0 ? ' idle' : '')}, [
el('div', {class: 'mark', 'aria-hidden': 'true'}),
el('div', {class: 'info'}, [
el('div', {class: 'label'},
imaging === 0
? 'No active imaging'
: (imaging + ' of ' + total + ' device' + (total === 1 ? '' : 's') + ' deploying')),
el('div', {class: 'bar-track'},
el('div', {class: 'bar-fill', style: 'width:' + pct + '%'})),
]),
]);
return root;
}
// Categorize an ISO row's "bootable now" status — drives the amber // Categorize an ISO row's "bootable now" status — drives the amber
// tint borrowed from Bootimus v0.1.62. Returns {ok, reason}. // tint borrowed from Bootimus v0.1.62. Returns {ok, reason}.
function bootability(iso, settings) { function bootability(iso, settings) {
@@ -100,7 +120,7 @@
const status = await getJSON('/api/status'); const status = await getJSON('/api/status');
const isos = await getJSON('/api/isos'); const isos = await getJSON('/api/isos');
const clients = (await getJSON('/api/clients')).clients || []; const clients = (await getJSON('/api/clients')).clients || [];
const gates = (await getJSON('/api/gate')).gates || []; const entries = (await getJSON('/api/queue')).entries || [];
const ipxeOk = (status.ipxe_assets || []).length > 0; const ipxeOk = (status.ipxe_assets || []).length > 0;
const stats = el('div', {class: 'statstrip'}, [ const stats = el('div', {class: 'statstrip'}, [
@@ -112,11 +132,14 @@
ipxeOk ? 'Bootloaders bundled, accepting clients' ipxeOk ? 'Bootloaders bundled, accepting clients'
: 'No iPXE binaries bundled'), : 'No iPXE binaries bundled'),
])), ])),
el('div', {class: 'card'}, el('div', {class: 'stat'}, [ el('div', {class: 'card'}, [
el('div', {class: 'label'}, 'Imaging now'), el('div', {class: 'stat', style: 'padding-bottom:0'}, [
el('div', {class: 'value'}, String(status.imaging_count || 0)), el('div', {class: 'label'}, 'Imaging now'),
el('div', {class: 'trend'}, (status.waiting_count || 0) + ' waiting at gate'), el('div', {class: 'value'}, String(status.imaging_count || 0)),
])), el('div', {class: 'trend'}, (status.waiting_count || 0) + ' waiting in queue'),
]),
queueProgressWidget(status.imaging_count || 0, status.queue_count || 0),
]),
el('div', {class: 'card'}, el('div', {class: 'stat'}, [ el('div', {class: 'card'}, el('div', {class: 'stat'}, [
el('div', {class: 'label'}, 'Images available'), el('div', {class: 'label'}, 'Images available'),
el('div', {class: 'value'}, String(isos.length)), el('div', {class: 'value'}, String(isos.length)),
@@ -128,7 +151,7 @@
el('div', {class: 'card'}, el('div', {class: 'stat'}, [ el('div', {class: 'card'}, el('div', {class: 'stat'}, [
el('div', {class: 'label'}, 'Uptime'), el('div', {class: 'label'}, 'Uptime'),
el('div', {class: 'value', style: 'font-size:22px'}, fmtUptime(status.uptime_secs)), el('div', {class: 'value', style: 'font-size:22px'}, fmtUptime(status.uptime_secs)),
el('div', {class: 'trend'}, 'PXEForge ' + status.version), el('div', {class: 'trend'}, 'OpenPXE ' + status.version),
])), ])),
]); ]);
@@ -136,10 +159,10 @@
// boot" log. Use last_seen desc (already sorted by API). // boot" log. Use last_seen desc (already sorted by API).
const recent = clients.slice(0, 8); const recent = clients.slice(0, 8);
const recentRows = recent.map(c => { const recentRows = recent.map(c => {
const g = gates.find(g => g.mac === c.mac); const g = entries.find(g => g.mac === c.mac);
let status = el('span', {class: 'tag ok'}, 'active'); let status = el('span', {class: 'tag ok'}, 'active');
if (g && g.assigned_target) status = el('span', {class:'tag ok'}, 'assigned: ' + g.assigned_target); if (g && g.assigned_target) status = el('span', {class:'tag ok'}, 'assigned: ' + g.assigned_target);
else if (g) status = el('span', {class:'tag accent'}, '#' + g.position + ' at gate'); else if (g) status = el('span', {class:'tag accent'}, '#' + g.position + ' in queue');
return el('tr', {}, [ return el('tr', {}, [
el('td', {class: 'mono'}, c.mac), el('td', {class: 'mono'}, c.mac),
el('td', {}, c.last_ip ? String(c.last_ip) : '-'), el('td', {}, c.last_ip ? String(c.last_ip) : '-'),
@@ -206,13 +229,13 @@
]), ]),
el('p', {class:'msg'}, el('p', {class:'msg'},
'Server IP, NIC, mask, and gateway are auto-detected at startup. ' + 'Server IP, NIC, mask, and gateway are auto-detected at startup. ' +
'To change them, set PXEFORGE_PUBLIC_IP and restart — editing them ' + 'To change them, set OPENPXE_PUBLIC_IP and restart — editing them ' +
'from a hot UI would silently break PXE for every client mid-boot.'), 'from a hot UI would silently break PXE for every client mid-boot.'),
el('label', {class:'field', style:'margin-top:18px'}, [ el('label', {class:'field', style:'margin-top:18px'}, [
el('span', {class:'name'}, 'DNS server (optional, informational)'), el('span', {class:'name'}, 'DNS server (optional, informational)'),
dns, dns,
el('span', {class:'hint'}, el('span', {class:'hint'},
'PXEForge does not run a DNS server itself; this field records ' + 'OpenPXE does not run a DNS server itself; this field records ' +
'what your upstream DNS is so you don\'t have to dig it out at ' + 'what your upstream DNS is so you don\'t have to dig it out at ' +
'3 AM during a deployment.'), '3 AM during a deployment.'),
]), ]),
@@ -224,8 +247,8 @@
}, },
gate: async () => { gate: async () => {
const [{ gates = [] }, isos] = await Promise.all([ const [{ entries = [] }, isos] = await Promise.all([
getJSON('/api/gate'), getJSON('/api/isos'), getJSON('/api/queue'), getJSON('/api/isos'),
]); ]);
const targets = isos.flatMap(i => i.boot_entries.map(e => ({ const targets = isos.flatMap(i => i.boot_entries.map(e => ({
id: e.id, title: e.title + ' — ' + familyLabel(i.introspection.family) id: e.id, title: e.title + ' — ' + familyLabel(i.introspection.family)
@@ -238,7 +261,7 @@
const msg = el('div', {class:'msg'}); const msg = el('div', {class:'msg'});
launch.onclick = async () => { launch.onclick = async () => {
if (!pick.value) { msg.textContent = 'Pick an image first.'; msg.className='msg err'; return; } if (!pick.value) { msg.textContent = 'Pick an image first.'; msg.className='msg err'; return; }
const r = await postJSON('/api/gate/assign', { target: pick.value, gate_ids: [] }); const r = await postJSON('/api/queue/assign', { target: pick.value, entry_ids: [] });
if (!r.ok) { msg.textContent = 'Assign failed: ' + r.status; msg.className='msg err'; return; } if (!r.ok) { msg.textContent = 'Assign failed: ' + r.status; msg.className='msg err'; return; }
const j = await r.json(); const j = await r.json();
if (!j.ok) { msg.textContent = 'Assign failed: ' + (j.error || 'unknown'); msg.className='msg err'; return; } if (!j.ok) { msg.textContent = 'Assign failed: ' + (j.error || 'unknown'); msg.className='msg err'; return; }
@@ -247,9 +270,9 @@
render('gate'); render('gate');
}; };
const track = gates.length const track = entries.length
? el('div', {class:'gate-track'}, ? el('div', {class:'queue-track'},
gates.map(g => el('div', {class:'gate-row' + (g.assigned_target ? ' assigned' : '')}, [ entries.map(g => el('div', {class:'queue-row' + (g.assigned_target ? ' assigned' : '')}, [
el('div', {class:'pos'}, '#' + g.position), el('div', {class:'pos'}, '#' + g.position),
el('div', {}, [ el('div', {}, [
el('div', {class:'mac'}, g.mac), el('div', {class:'mac'}, g.mac),
@@ -260,15 +283,21 @@
? el('span', {class:'tag ok'}, '→ ' + g.assigned_target) ? el('span', {class:'tag ok'}, '→ ' + g.assigned_target)
: el('span', {class:'tag accent'}, 'waiting')), : el('span', {class:'tag accent'}, 'waiting')),
el('button', {class:'ghost', onclick: async () => { el('button', {class:'ghost', onclick: async () => {
await fetch('/api/gate/' + encodeURIComponent(g.id), {method:'DELETE'}); await fetch('/api/queue/' + encodeURIComponent(g.id), {method:'DELETE'});
render('gate'); render('gate');
}}, 'Release'), }}, 'Release'),
])) ]))
) )
: el('div', {class:'empty'}, : el('div', {class:'empty'},
'No clients at the gate. Boot a client and choose "Gated Deployment" in the PXE menu.'); 'No clients at the gate. Boot a client and choose "Queued Deployment" in the PXE menu.');
const imaging = entries.filter(g => g.assigned_target).length;
return el('div', {class:'grid'}, [ return el('div', {class:'grid'}, [
el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Forge')),
queueProgressWidget(imaging, entries.length),
]),
el('div', {class:'card'}, [ el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Launch an image across the gate')), el('header', {}, el('h2', {}, 'Launch an image across the gate')),
el('div', {class:'body'}, [ el('div', {class:'body'}, [
@@ -284,7 +313,7 @@
el('div', {class:'card'}, [ el('div', {class:'card'}, [
el('header', {}, [ el('header', {}, [
el('h2', {}, 'Gate positions'), el('h2', {}, 'Gate positions'),
el('span', {class:'sub'}, gates.length + ' waiting'), el('span', {class:'sub'}, entries.length + ' waiting'),
]), ]),
el('div', {class:'body'}, track), el('div', {class:'body'}, track),
]), ]),
@@ -476,6 +505,99 @@
]); ]);
}, },
hosts: async () => {
const [{ hosts = [] }, isos] = await Promise.all([
getJSON('/api/hosts'), getJSON('/api/isos'),
]);
const targets = isos.flatMap(i => i.boot_entries.map(e => ({
id: e.id, title: e.title + ' — ' + familyLabel(i.introspection.family),
})));
// Reserved menu shortcuts that the operator might want to bind.
const reserved = [
{id: '_local', title: '↳ Boot from Local HDD (built-in)'},
{id: '_queue', title: '↳ Queued Deployment (built-in)'},
{id: '_tools_menu', title: '↳ Tools menu (built-in)'},
];
const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff', spellcheck:'false'});
const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'});
const targetSel = el('select', {},
[el('option', {value:''}, '— choose a target —')]
.concat(reserved.map(t => el('option', {value: t.id}, t.title)))
.concat(targets.map(t => el('option', {value: t.id}, t.title))));
const msg = el('div', {class:'msg'});
const upsertBtn = el('button', {onclick: async () => {
if (!macInput.value || !targetSel.value) {
msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
}
const r = await postJSON('/api/hosts', {
mac: macInput.value, target: targetSel.value, label: labelInput.value,
});
if (r.ok) {
msg.textContent = 'Saved.'; msg.className = 'msg ok';
render('hosts');
} else {
const t = await r.text();
msg.textContent = 'Save failed: ' + t; msg.className = 'msg err';
}
}}, 'Bind MAC to target');
const rows = hosts.map(h => el('tr', {}, [
el('td', {class:'mono'}, h.mac),
el('td', {}, h.label || el('span', {class:'tag'}, '(unlabeled)')),
el('td', {class:'mono'}, h.target),
el('td', {}, fmtAgo(h.updated_at)),
el('td', {style:'text-align:right'},
el('button', {class:'danger', onclick: async () => {
if (!confirm('Remove binding for ' + h.mac + '?')) return;
await fetch('/api/hosts/' + encodeURIComponent(h.mac), {method:'DELETE'});
render('hosts');
}}, 'Remove')),
]));
const table = hosts.length
? el('table', {}, [
el('thead', {}, el('tr', {}, [
el('th',{},'MAC'), el('th',{},'Label'),
el('th',{},'Target'), el('th',{},'Updated'), el('th',{},''),
])),
el('tbody', {}, rows),
])
: el('div', {class:'empty'}, 'No host bindings yet. Pin a MAC to a boot target to skip the menu for that machine.');
return el('div', {class:'grid'}, [
el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Pin MAC to boot target')),
el('div', {class:'body'}, [
el('div', {class:'form-row'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'MAC address'), macInput]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]),
el('label', {class:'field', style:'grid-column:1 / -1'}, [
el('span', {class:'name'}, 'Target'),
targetSel,
el('span', {class:'hint'},
'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'),
]),
]),
upsertBtn, msg,
el('p', {class:'msg', style:'margin-top:14px'},
'When a client with a bound MAC requests boot.ipxe, OpenPXE ' +
'short-circuits past the interactive menu and chains directly. ' +
'Inspired by Tinkerbell smee\'s MAC-prepended URL pattern.'),
]),
]),
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Bound hosts'),
el('span', {class:'sub'}, hosts.length + ' binding' + (hosts.length === 1 ? '' : 's')),
]),
table,
]),
]);
},
terminal: async () => { terminal: async () => {
// Two-pane layout: live log on top (auto-scrolling), command line // Two-pane layout: live log on top (auto-scrolling), command line
// on bottom. Mirrors the Minecraft-server console feel from the // on bottom. Mirrors the Minecraft-server console feel from the
@@ -507,7 +629,7 @@
const ts = (line.timestamp || new Date().toISOString()).replace(/\.\d+/, '').replace('T', ' ').replace('Z', ''); const ts = (line.timestamp || new Date().toISOString()).replace(/\.\d+/, '').replace('T', ' ').replace('Z', '');
const span = el('span', {class: 'lvl-' + lvl}, [ const span = el('span', {class: 'lvl-' + lvl}, [
el('span', {class:'ts'}, ts + ' '), el('span', {class:'ts'}, ts + ' '),
el('span', {class:'tg'}, '[' + (line.target || 'pxeforge') + '] '), el('span', {class:'tg'}, '[' + (line.target || 'openpxe') + '] '),
line.message, line.message,
'\n', '\n',
]); ]);
@@ -525,7 +647,7 @@
const r = await getJSON('/api/log/recent'); const r = await getJSON('/api/log/recent');
for (const l of (r.lines || [])) append(l); for (const l of (r.lines || [])) append(l);
} catch (e) { } catch (e) {
append({timestamp: new Date().toISOString(), level:'warn', target:'pxeforge::ui', append({timestamp: new Date().toISOString(), level:'warn', target:'openpxe::ui',
message: 'failed to load recent logs: ' + e.message}); message: 'failed to load recent logs: ' + e.message});
} }
@@ -533,12 +655,12 @@
const es = new EventSource('/api/log/stream'); const es = new EventSource('/api/log/stream');
es.onmessage = (ev) => { es.onmessage = (ev) => {
try { append(JSON.parse(ev.data)); } try { append(JSON.parse(ev.data)); }
catch { append({timestamp: new Date().toISOString(), level:'debug', target:'pxeforge::ui', message: ev.data}); } catch { append({timestamp: new Date().toISOString(), level:'debug', target:'openpxe::ui', message: ev.data}); }
}; };
es.addEventListener('lagged', (ev) => { es.addEventListener('lagged', (ev) => {
const j = JSON.parse(ev.data || '{}'); const j = JSON.parse(ev.data || '{}');
append({timestamp: new Date().toISOString(), level:'warn', append({timestamp: new Date().toISOString(), level:'warn',
target:'pxeforge::ui', target:'openpxe::ui',
message: 'log stream lagged: ' + (j.skipped || '?') + ' lines skipped'}); message: 'log stream lagged: ' + (j.skipped || '?') + ' lines skipped'});
}); });
es.onerror = () => { es.onerror = () => {
@@ -546,7 +668,7 @@
// We only append once, on transition from connected → erroring. // We only append once, on transition from connected → erroring.
if (!term._notedErr) { if (!term._notedErr) {
term._notedErr = true; term._notedErr = true;
append({timestamp: new Date().toISOString(), level:'warn', target:'pxeforge::ui', append({timestamp: new Date().toISOString(), level:'warn', target:'openpxe::ui',
message: 'log stream connection lost — auto-reconnecting'}); message: 'log stream connection lost — auto-reconnecting'});
setTimeout(() => { term._notedErr = false; }, 5000); setTimeout(() => { term._notedErr = false; }, 5000);
} }
@@ -579,7 +701,7 @@
target: 'terminal-output', message: out}); target: 'terminal-output', message: out});
} catch (err) { } catch (err) {
append({timestamp: new Date().toISOString(), level:'error', append({timestamp: new Date().toISOString(), level:'error',
target:'pxeforge::ui', message: 'command failed: ' + err.message}); target:'openpxe::ui', message: 'command failed: ' + err.message});
} }
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
if (history.length === 0) return; if (history.length === 0) return;
@@ -594,7 +716,7 @@
}); });
// Welcome banner. // Welcome banner.
append({timestamp: new Date().toISOString(), level:'info', target:'pxeforge::terminal', append({timestamp: new Date().toISOString(), level:'info', target:'openpxe::terminal',
message: 'Connected. Type "help" for available commands.'}); message: 'Connected. Type "help" for available commands.'});
// Focus the input on next tick (after view swap completes). // Focus the input on next tick (after view swap completes).
@@ -607,7 +729,7 @@
const status = await getJSON('/api/status'); const status = await getJSON('/api/status');
return el('div', {class:'card'}, [ return el('div', {class:'card'}, [
el('div', {class:'about-hero'}, [ el('div', {class:'about-hero'}, [
el('h2', {}, 'PXEForge'), el('h2', {}, 'OpenPXE'),
el('p', {class:'lead'}, el('p', {class:'lead'},
'Air-gapped network PXE boot, container-native, that anyone can run. ' + 'Air-gapped network PXE boot, container-native, that anyone can run. ' +
'No CDN calls, no telemetry, no surprise external dependencies — ship ' + 'No CDN calls, no telemetry, no surprise external dependencies — ship ' +
@@ -636,12 +758,37 @@
const viewTitles = { const viewTitles = {
dashboard: 'Dashboard', dashboard: 'Dashboard',
network: 'Network', network: 'Network',
gate: 'Forge Gate', gate: 'Queue',
storage: 'Storage', storage: 'Storage',
hosts: 'Hosts',
terminal: 'Terminal', terminal: 'Terminal',
about: 'About', about: 'About',
}; };
// Theme toggle. The data-attribute is set on <html> by the inline
// script in index.html before paint; we just flip it here and persist.
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('openpxe-theme', theme); } catch {}
}
function currentTheme() {
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
}
document.addEventListener('DOMContentLoaded', () => {
const btn = $('#theme-toggle');
if (btn) {
btn.addEventListener('click', () => {
applyTheme(currentTheme() === 'light' ? 'dark' : 'light');
});
}
});
// Keyboard shortcut: T toggles theme (skip when typing in an input).
document.addEventListener('keydown', (e) => {
if (e.key !== 't' && e.key !== 'T') return;
if (/^(INPUT|TEXTAREA|SELECT)$/.test((e.target && e.target.tagName) || '')) return;
applyTheme(currentTheme() === 'light' ? 'dark' : 'light');
});
let currentBody = null; let currentBody = null;
async function render(view) { async function render(view) {
@@ -654,7 +801,13 @@
try { currentBody._cleanup(); } catch (e) { /* ignore */ } try { currentBody._cleanup(); } catch (e) { /* ignore */ }
} }
root.innerHTML = ''; root.innerHTML = '';
root.appendChild(el('div', {class:'msg'}, 'Loading…')); // Animated rainbow-mark loader replacing the old plain-text
// \"Loading…\". The SVG drives all animation via SMIL — no JS, no
// CSS keyframes.
root.appendChild(el('div', {class:'loader'}, [
el('div', {class:'mark'}),
el('div', {}, 'Loading…'),
]));
try { try {
const body = await views[view](); const body = await views[view]();
root.innerHTML = ''; root.innerHTML = '';
@@ -673,7 +826,8 @@
$$('[data-bind=version]').forEach(n => n.textContent = s.version); $$('[data-bind=version]').forEach(n => n.textContent = s.version);
$$('[data-bind=iso_count],[data-bind=iso_count2]').forEach(n => n.textContent = String(s.iso_count)); $$('[data-bind=iso_count],[data-bind=iso_count2]').forEach(n => n.textContent = String(s.iso_count));
$$('[data-bind=client_count],[data-bind=client_count2]').forEach(n => n.textContent = String(s.client_count)); $$('[data-bind=client_count],[data-bind=client_count2]').forEach(n => n.textContent = String(s.client_count));
$$('[data-bind=gate_count],[data-bind=gate_count2]').forEach(n => n.textContent = String(s.gate_count)); $$('[data-bind=queue_count],[data-bind=queue_count2]').forEach(n => n.textContent = String(s.queue_count));
$$('[data-bind=host_count]').forEach(n => n.textContent = String(s.host_bindings || 0));
const chip = $('[data-bind=ready_chip]'); const chip = $('[data-bind=ready_chip]');
if (chip) { if (chip) {
if (r.ok) { chip.textContent = '● ready'; chip.className = 'chip ready'; } if (r.ok) { chip.textContent = '● ready'; chip.className = 'chip ready'; }
+46 -7
View File
@@ -3,9 +3,24 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PXEForge</title> <meta name="color-scheme" content="dark light" />
<title>OpenPXE</title>
<link rel="stylesheet" href="/assets/app.css" /> <link rel="stylesheet" href="/assets/app.css" />
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg" /> <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 theme = stored || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
</head> </head>
<body> <body>
<div class="shell"> <div class="shell">
@@ -13,21 +28,25 @@
<div class="brand"> <div class="brand">
<img src="/assets/logo.svg" alt="" /> <img src="/assets/logo.svg" alt="" />
<div> <div>
<strong>PXEForge</strong> <strong>OpenPXE</strong>
<div class="sub">v<span data-bind="version">0.1.0</span></div> <div class="sub">v<span data-bind="version">0.3.0</span></div>
</div> </div>
</div> </div>
<nav> <nav>
<a data-view="dashboard" class="active">Dashboard</a> <a data-view="dashboard" class="active">Dashboard</a>
<a data-view="network">Network</a> <a data-view="network">Network</a>
<a data-view="gate"> <a data-view="queue">
Forge Gate Queue
<span class="count" data-bind="gate_count">0</span> <span class="count" data-bind="queue_count">0</span>
</a> </a>
<a data-view="storage"> <a data-view="storage">
Storage Storage
<span class="count" data-bind="iso_count">0</span> <span class="count" data-bind="iso_count">0</span>
</a> </a>
<a data-view="hosts">
Hosts
<span class="count" data-bind="host_count">0</span>
</a>
<a data-view="terminal">Terminal</a> <a data-view="terminal">Terminal</a>
<a data-view="about">About</a> <a data-view="about">About</a>
</nav> </nav>
@@ -43,7 +62,27 @@
<span class="chip" data-bind="ready_chip" title="Server readiness">checking…</span> <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="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="client_count2">0</strong>&nbsp;clients</span>
<span class="chip"><strong data-bind="gate_count2">0</strong>&nbsp;at gate</span> <span class="chip"><strong data-bind="queue_count2">0</strong>&nbsp;in queue</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. -->
<svg class="t-sun" viewBox="0 0 24 24" width="18" height="18" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="12" cy="12" r="4.2"/>
<line x1="12" y1="2.5" x2="12" y2="5.5"/>
<line x1="12" y1="18.5" x2="12" y2="21.5"/>
<line x1="2.5" y1="12" x2="5.5" y2="12"/>
<line x1="18.5" y1="12" x2="21.5" y2="12"/>
<line x1="5.2" y1="5.2" x2="7.3" y2="7.3"/>
<line x1="16.7" y1="16.7" x2="18.8" y2="18.8"/>
<line x1="5.2" y1="18.8" x2="7.3" y2="16.7"/>
<line x1="16.7" y1="7.3" x2="18.8" y2="5.2"/>
</svg>
<svg class="t-moon" viewBox="0 0 24 24" width="18" height="18" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20.5 14A8 8 0 0 1 10 3.5 a8 8 0 1 0 10.5 10.5z"/>
</svg>
</button>
</header> </header>
<main class="main" id="view-root"></main> <main class="main" id="view-root"></main>
+12 -5
View File
@@ -1,7 +1,7 @@
//! Offline-only web UI. Everything the browser needs (HTML, CSS, JS, SVG //! Offline-only web UI. Everything the browser needs (HTML, CSS, JS, SVG
//! logo) is embedded in the compiled binary via `include_str!` / //! logo) is embedded in the compiled binary via `include_str!` /
//! `include_bytes!`. No CDN, no external fonts, no remote images — //! `include_bytes!`. No CDN, no external fonts, no remote images —
//! PXEForge renders identically on an air-gapped network. //! OpenPXE renders identically on an air-gapped network.
//! //!
//! Layout follows the Netbox Labs pattern: dark left sidebar with primary //! Layout follows the Netbox Labs pattern: dark left sidebar with primary
//! nav, top bar with secondary tabs, card-dense content panels. //! nav, top bar with secondary tabs, card-dense content panels.
@@ -23,7 +23,14 @@ pub fn app_css() -> &'static str { APP_CSS }
#[must_use] #[must_use]
pub fn logo_svg() -> &'static str { LOGO_SVG } pub fn logo_svg() -> &'static str { LOGO_SVG }
const INDEX_HTML: &str = include_str!("index.html"); /// Larger, faster-cycling rainbow disc — used for the page-load
const APP_CSS: &str = include_str!("app.css"); /// transition and the imaging-progress widget on Dashboard / Queue.
const APP_JS: &str = include_str!("app.js"); /// Pure SVG + SMIL, no JS, no GIF.
const LOGO_SVG: &str = include_str!("logo.svg"); #[must_use]
pub fn loader_svg() -> &'static 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 LOADER_SVG: &str = include_str!("loader.svg");
+36
View File
@@ -0,0 +1,36 @@
<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>

After

Width:  |  Height:  |  Size: 1.8 KiB

+24 -13
View File
@@ -1,14 +1,25 @@
<svg viewBox="0 0 96 64" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>PXEForge</title> <title>OpenPXE</title>
<!-- Anvil body --> <!-- Brand mark to match openpxe.com: a circular medallion filled with
<path d="M6 22 H82 L70 36 H46 V44 H58 V50 H30 V44 H42 V36 H22 Z" fill="#f0823a" stroke="#3a1f08" stroke-width="1.2"/> the "rainbow-horizon" gradient, sliding 200% across to give a slow
<!-- Horn highlight --> hue rotation. Subtle stroke + drop shadow for legibility on either
<path d="M6 22 L20 22 L14 28 L6 28 Z" fill="#ffb066"/> theme. SMIL keeps it self-driving with no JS or CSS dependency. -->
<!-- Stand + base --> <defs>
<rect x="34" y="50" width="20" height="4" fill="#3a1f08"/> <linearGradient id="opxRainbow" x1="0" y1="0" x2="1" y2="0">
<rect x="22" y="54" width="44" height="6" fill="#1c1107"/> <stop offset="0%" stop-color="#330f1f"/>
<!-- Subtle spark --> <stop offset="12.56%" stop-color="#c83228"/>
<circle cx="86" cy="16" r="1.5" fill="#ffd79a"/> <stop offset="25.06%" stop-color="#fb8841"/>
<circle cx="90" cy="22" r="1" fill="#ffd79a"/> <stop offset="37.56%" stop-color="#d3dd92"/>
<circle cx="82" cy="12" r="1" fill="#ffd79a"/> <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>
</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"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 650 B

After

Width:  |  Height:  |  Size: 1.3 KiB

+19 -19
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
# #
# PXEForge — multi-stage build. # OpenPXE — multi-stage build.
# #
# Design: # Design:
# - stage `fetch`: runs scripts/fetch-ipxe.sh to pull official iPXE binaries # - stage `fetch`: runs scripts/fetch-ipxe.sh to pull official iPXE binaries
@@ -8,7 +8,7 @@
# - stage `build`: compiles the workspace with cargo in release mode. # - stage `build`: compiles the workspace with cargo in release mode.
# - stage `runtime`: Debian slim image with setcap for NET_BIND_SERVICE, # - 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; # running as a non-root UID. No shell in PATH for the service user;
# attacker surface is just the pxeforge binary + libc. # attacker surface is just the openpxe binary + libc.
# #
# Why not distroless? We want setcap support and easy debug (`oc rsh`). # 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 # Debian slim at ~75 MB + binary ~25 MB is fine for a PXE server that
@@ -24,7 +24,7 @@ WORKDIR /src
COPY scripts/fetch-ipxe.sh scripts/fetch-ipxe.sh COPY scripts/fetch-ipxe.sh scripts/fetch-ipxe.sh
RUN mkdir -p assets/ipxe && bash scripts/fetch-ipxe.sh RUN mkdir -p assets/ipxe && bash scripts/fetch-ipxe.sh
########## build pxeforge ########## ########## build openpxe ##########
FROM rust:${RUST_VERSION}-bookworm AS build FROM rust:${RUST_VERSION}-bookworm AS build
WORKDIR /src WORKDIR /src
@@ -43,9 +43,9 @@ COPY --from=fetch /src/assets/ipxe /src/assets/ipxe
RUN --mount=type=cache,target=/usr/local/cargo/registry \ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/src/target,sharing=locked \ --mount=type=cache,target=/src/target,sharing=locked \
find crates -name '*.rs' -exec touch {} + && \ find crates -name '*.rs' -exec touch {} + && \
cargo build --release --bin pxeforge && \ cargo build --release --bin openpxe && \
cp target/release/pxeforge /pxeforge && \ cp target/release/openpxe /openpxe && \
ls -l /pxeforge ls -l /openpxe
########## runtime ########## ########## runtime ##########
FROM debian:12-slim AS runtime FROM debian:12-slim AS runtime
@@ -54,13 +54,13 @@ RUN apt-get update \
ca-certificates libcap2-bin tini gosu iproute2 \ ca-certificates libcap2-bin tini gosu iproute2 \
wimtools samba nfs-common \ wimtools samba nfs-common \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10001 --home-dir /var/lib/pxeforge --shell /usr/sbin/nologin pxeforge \ && useradd --system --uid 10001 --home-dir /var/lib/openpxe --shell /usr/sbin/nologin openpxe \
&& mkdir -p /var/lib/pxeforge/isos /var/lib/pxeforge/work /var/lib/pxeforge/smb \ && mkdir -p /var/lib/openpxe/isos /var/lib/openpxe/work /var/lib/openpxe/smb \
&& chown -R pxeforge:pxeforge /var/lib/pxeforge && chown -R openpxe:openpxe /var/lib/openpxe
# Runtime deps explained: # Runtime deps explained:
# wimtools - provides `wimlib-imagex`, used to inject startnet.cmd into boot.wim. # wimtools - provides `wimlib-imagex`, used to inject startnet.cmd into boot.wim.
# samba - `smbd` serves extracted Windows install media on :445 for WinPE # samba - `smbd` serves extracted Windows install media on :445 for WinPE
# to `net use`. Guest read-only, scoped to /var/lib/pxeforge/smb. # to `net use`. Guest read-only, scoped to /var/lib/openpxe/smb.
# nfs-common - provides `mount.nfs` / `mount.nfs4` for the Storage tab's # nfs-common - provides `mount.nfs` / `mount.nfs4` for the Storage tab's
# NFS share manager. Mount also requires the container to run # NFS share manager. Mount also requires the container to run
# with CAP_SYS_ADMIN — without it, mount(2) returns EPERM and # with CAP_SYS_ADMIN — without it, mount(2) returns EPERM and
@@ -74,23 +74,23 @@ RUN apt-get update \
# bind-mount ownership (common OpenShift/Docker UX issue). # bind-mount ownership (common OpenShift/Docker UX issue).
# Windows-specific tools only activate when the WebUI toggle is on. # Windows-specific tools only activate when the WebUI toggle is on.
COPY --from=build /pxeforge /usr/local/bin/pxeforge COPY --from=build /openpxe /usr/local/bin/openpxe
COPY deploy/docker/entrypoint.sh /usr/local/bin/entrypoint.sh COPY deploy/docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /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. # Grant the binary the ability to bind <1024 ports as a non-root user.
# This is the only capability PXEForge needs for proxy-mode DHCP + TFTP + HTTP. # This is the only capability OpenPXE needs for proxy-mode DHCP + TFTP + HTTP.
RUN setcap cap_net_bind_service=+ep /usr/local/bin/pxeforge RUN setcap cap_net_bind_service=+ep /usr/local/bin/openpxe
# IMPORTANT: we do NOT `USER pxeforge` here. The entrypoint runs as root, # IMPORTANT: we do NOT `USER openpxe` here. The entrypoint runs as root,
# chowns the mounted data dirs, then execs the binary via gosu as pxeforge. # chowns the mounted data dirs, then execs the binary via gosu as openpxe.
# OpenShift ignores USER directives anyway (it injects its own uid), and # OpenShift ignores USER directives anyway (it injects its own uid), and
# there entrypoint.sh's non-root branch just execs directly. # there entrypoint.sh's non-root branch just execs directly.
WORKDIR /var/lib/pxeforge WORKDIR /var/lib/openpxe
ENV PXEFORGE_ISO_DIR=/var/lib/pxeforge/isos \ ENV OPENPXE_ISO_DIR=/var/lib/openpxe/isos \
PXEFORGE_WORK_DIR=/var/lib/pxeforge/work \ OPENPXE_WORK_DIR=/var/lib/openpxe/work \
PXEFORGE_LOG=info,pxeforge=info OPENPXE_LOG=info,openpxe=info
EXPOSE 67/udp 69/udp 4011/udp 80/tcp 445/tcp 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. # Container entrypoint that handles the common bind-mount-as-root case.
# #
# When volumes are bind-mounted into the container (e.g. `-v ./data/isos:...`), # 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 pxeforge # they come up owned by the host uid:gid — often root:root. The openpxe
# binary runs as uid 10001 and can't write there. This script, when started # binary runs as uid 10001 and can't write there. This script, when started
# as root, chowns the two state dirs to the pxeforge user, then drops # as root, chowns the two state dirs to the openpxe user, then drops
# privileges via gosu before execing the binary. # privileges via gosu before execing the binary.
# #
# If the container is already running as non-root (OpenShift does this via # 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. # or the operator is on their own for permissions.
set -e set -e
PXEFORGE_UID=${PXEFORGE_UID:-10001} OPENPXE_UID=${OPENPXE_UID:-10001}
PXEFORGE_GID=${PXEFORGE_GID:-10001} OPENPXE_GID=${OPENPXE_GID:-10001}
DATA_DIRS="/var/lib/pxeforge/isos /var/lib/pxeforge/work /var/lib/pxeforge/smb" DATA_DIRS="/var/lib/openpxe/isos /var/lib/openpxe/work /var/lib/openpxe/smb"
if [ "$(id -u)" = "0" ]; then if [ "$(id -u)" = "0" ]; then
for d in $DATA_DIRS; do for d in $DATA_DIRS; do
if [ -d "$d" ]; then if [ -d "$d" ]; then
chown -R "${PXEFORGE_UID}:${PXEFORGE_GID}" "$d" 2>/dev/null || true chown -R "${OPENPXE_UID}:${OPENPXE_GID}" "$d" 2>/dev/null || true
fi fi
done done
# Re-exec ourselves under the pxeforge user so the binary inherits a # Re-exec ourselves under the openpxe user so the binary inherits a
# clean process environment and a predictable umask. # clean process environment and a predictable umask.
exec gosu "${PXEFORGE_UID}:${PXEFORGE_GID}" /usr/local/bin/pxeforge "$@" exec gosu "${OPENPXE_UID}:${OPENPXE_GID}" /usr/local/bin/openpxe "$@"
fi fi
# Non-root: straight exec, no chown attempt. # Non-root: straight exec, no chown attempt.
exec /usr/local/bin/pxeforge "$@" exec /usr/local/bin/openpxe "$@"
+1 -1
View File
@@ -1,7 +1,7 @@
apiVersion: v1 apiVersion: v1
kind: Namespace kind: Namespace
metadata: metadata:
name: pxeforge name: openpxe
labels: labels:
# Allow privileged pods (host-network) in this namespace only. The pod # Allow privileged pods (host-network) in this namespace only. The pod
# itself still runs non-root with only NET_BIND_SERVICE — privileged # itself still runs non-root with only NET_BIND_SERVICE — privileged
+11 -11
View File
@@ -1,5 +1,5 @@
--- ---
# Custom SCC for PXEForge. # Custom SCC for OpenPXE.
# #
# The default `restricted-v2` SCC blocks host network and all capabilities, # The default `restricted-v2` SCC blocks host network and all capabilities,
# which PXE cannot tolerate: DHCPDISCOVER is an L2 broadcast that CNI overlays # which PXE cannot tolerate: DHCPDISCOVER is an L2 broadcast that CNI overlays
@@ -19,10 +19,10 @@
apiVersion: security.openshift.io/v1 apiVersion: security.openshift.io/v1
kind: SecurityContextConstraints kind: SecurityContextConstraints
metadata: metadata:
name: pxeforge-scc name: openpxe-scc
annotations: annotations:
kubernetes.io/description: >- kubernetes.io/description: >-
Minimal SCC for PXEForge: host network + NET_BIND_SERVICE only, no raw Minimal SCC for OpenPXE: host network + NET_BIND_SERVICE only, no raw
sockets, no privileged mode. sockets, no privileged mode.
allowPrivilegedContainer: false allowPrivilegedContainer: false
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
@@ -54,27 +54,27 @@ volumes:
users: [] users: []
groups: [] groups: []
--- ---
# Bind the SCC to the pxeforge service account. # Bind the SCC to the openpxe service account.
kind: ClusterRole kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1 apiVersion: rbac.authorization.k8s.io/v1
metadata: metadata:
name: pxeforge-scc-use name: openpxe-scc-use
rules: rules:
- apiGroups: ["security.openshift.io"] - apiGroups: ["security.openshift.io"]
resources: ["securitycontextconstraints"] resources: ["securitycontextconstraints"]
resourceNames: ["pxeforge-scc"] resourceNames: ["openpxe-scc"]
verbs: ["use"] verbs: ["use"]
--- ---
kind: RoleBinding kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1 apiVersion: rbac.authorization.k8s.io/v1
metadata: metadata:
name: pxeforge-scc-use name: openpxe-scc-use
namespace: pxeforge namespace: openpxe
roleRef: roleRef:
apiGroup: rbac.authorization.k8s.io apiGroup: rbac.authorization.k8s.io
kind: ClusterRole kind: ClusterRole
name: pxeforge-scc-use name: openpxe-scc-use
subjects: subjects:
- kind: ServiceAccount - kind: ServiceAccount
name: pxeforge name: openpxe
namespace: pxeforge namespace: openpxe
+9 -9
View File
@@ -2,29 +2,29 @@
apiVersion: v1 apiVersion: v1
kind: ServiceAccount kind: ServiceAccount
metadata: metadata:
name: pxeforge name: openpxe
namespace: pxeforge namespace: openpxe
--- ---
apiVersion: v1 apiVersion: v1
kind: ConfigMap kind: ConfigMap
metadata: metadata:
name: pxeforge-config name: openpxe-config
namespace: pxeforge namespace: openpxe
data: data:
# Toggle DHCP proxy on or off. "proxy" = answer PXE clients alongside an # Toggle DHCP proxy on or off. "proxy" = answer PXE clients alongside an
# existing DHCP server. "disabled" = require operator to point an external # existing DHCP server. "disabled" = require operator to point an external
# DHCP at us via next-server/filename. # DHCP at us via next-server/filename.
PXEFORGE_DHCP_MODE: "proxy" OPENPXE_DHCP_MODE: "proxy"
# Override if auto-detection picks the wrong NIC in multi-homed pods. # Override if auto-detection picks the wrong NIC in multi-homed pods.
# Leave unset to auto-detect from the node's primary IPv4. # Leave unset to auto-detect from the node's primary IPv4.
# PXEFORGE_PUBLIC_IP: "10.0.0.5" # OPENPXE_PUBLIC_IP: "10.0.0.5"
PXEFORGE_LOG: "info,pxeforge=info" OPENPXE_LOG: "info,openpxe=info"
--- ---
apiVersion: v1 apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
metadata: metadata:
name: pxeforge-isos name: openpxe-isos
namespace: pxeforge namespace: openpxe
spec: spec:
# ReadWriteOnce is fine — we deploy as a single replica since DHCP proxy # ReadWriteOnce is fine — we deploy as a single replica since DHCP proxy
# coordination across replicas is not useful (clients hit whichever node # coordination across replicas is not useful (clients hit whichever node
+12 -12
View File
@@ -2,10 +2,10 @@
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: pxeforge name: openpxe
namespace: pxeforge namespace: openpxe
labels: labels:
app.kubernetes.io/name: pxeforge app.kubernetes.io/name: openpxe
spec: spec:
# Single replica by design (see PVC comment). If HA is needed later, split # 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 # the HTTP/web plane (scalable, stateless) from the DHCP-proxy/TFTP plane
@@ -15,13 +15,13 @@ spec:
type: Recreate type: Recreate
selector: selector:
matchLabels: matchLabels:
app.kubernetes.io/name: pxeforge app.kubernetes.io/name: openpxe
template: template:
metadata: metadata:
labels: labels:
app.kubernetes.io/name: pxeforge app.kubernetes.io/name: openpxe
spec: spec:
serviceAccountName: pxeforge serviceAccountName: openpxe
# L2 broadcast (DHCPDISCOVER) does not cross most CNI overlays into # L2 broadcast (DHCPDISCOVER) does not cross most CNI overlays into
# pod netns. Host network is the working path. # pod netns. Host network is the working path.
hostNetwork: true hostNetwork: true
@@ -33,8 +33,8 @@ spec:
runAsUser: 10001 runAsUser: 10001
fsGroup: 10001 fsGroup: 10001
containers: containers:
- name: pxeforge - name: openpxe
image: ghcr.io/casperadmin/pxeforge:0.1.0 image: ghcr.io/casperadmin/openpxe:0.1.0
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- name: dhcp - name: dhcp
@@ -59,7 +59,7 @@ spec:
protocol: TCP protocol: TCP
envFrom: envFrom:
- configMapRef: - configMapRef:
name: pxeforge-config name: openpxe-config
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
readOnlyRootFilesystem: true readOnlyRootFilesystem: true
@@ -70,9 +70,9 @@ spec:
add: ["NET_BIND_SERVICE"] add: ["NET_BIND_SERVICE"]
volumeMounts: volumeMounts:
- name: isos - name: isos
mountPath: /var/lib/pxeforge/isos mountPath: /var/lib/openpxe/isos
- name: work - name: work
mountPath: /var/lib/pxeforge/work mountPath: /var/lib/openpxe/work
- name: tmp - name: tmp
mountPath: /tmp mountPath: /tmp
readinessProbe: readinessProbe:
@@ -97,7 +97,7 @@ spec:
volumes: volumes:
- name: isos - name: isos
persistentVolumeClaim: persistentVolumeClaim:
claimName: pxeforge-isos claimName: openpxe-isos
- name: work - name: work
emptyDir: {} emptyDir: {}
- name: tmp - name: tmp
+7 -7
View File
@@ -5,14 +5,14 @@
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: pxeforge name: openpxe
namespace: pxeforge namespace: openpxe
labels: labels:
app.kubernetes.io/name: pxeforge app.kubernetes.io/name: openpxe
spec: spec:
type: ClusterIP type: ClusterIP
selector: selector:
app.kubernetes.io/name: pxeforge app.kubernetes.io/name: openpxe
ports: ports:
- name: http - name: http
port: 80 port: 80
@@ -29,12 +29,12 @@ spec:
apiVersion: route.openshift.io/v1 apiVersion: route.openshift.io/v1
kind: Route kind: Route
metadata: metadata:
name: pxeforge name: openpxe
namespace: pxeforge namespace: openpxe
spec: spec:
to: to:
kind: Service kind: Service
name: pxeforge name: openpxe
weight: 100 weight: 100
port: port:
targetPort: http targetPort: http
+23 -23
View File
@@ -1,12 +1,12 @@
# PXEForge on Unraid # OpenPXE on Unraid
Three paths from "I have an Unraid box with Gitea on it" to "PXE clients Three paths from "I have an Unraid box with Gitea on it" to "PXE clients
boot from PXEForge". Pick the one that matches what you have. boot from OpenPXE". Pick the one that matches what you have.
## Path A — build on Unraid, push to Gitea registry, pull by tag ## Path A — build on Unraid, push to Gitea registry, pull by tag
Recommended once you've done it once. Image is published to Recommended once you've done it once. Image is published to
`gitea.milesward.dev/mward4/pxeforge:0.1.0` (or your equivalent) and `gitea.milesward.dev/mward4/openpxe:0.1.0` (or your equivalent) and
every Unraid template / docker-compose just references the tag. every Unraid template / docker-compose just references the tag.
Pre-flight: Pre-flight:
@@ -24,12 +24,12 @@ Run on the Unraid host (Settings → Terminal, or `ssh root@unraid`):
GITEA_TOKEN=<your-token> GITEA_TOKEN=<your-token>
curl -fsSL \ curl -fsSL \
-H "Authorization: token $GITEA_TOKEN" \ -H "Authorization: token $GITEA_TOKEN" \
http://localhost:3000/mward4/PXEForge/raw/branch/main/scripts/build-and-publish-unraid.sh \ http://localhost:3000/mward4/OpenPXE/raw/branch/main/scripts/build-and-publish-unraid.sh \
-o /tmp/pxeforge-publish.sh -o /tmp/openpxe-publish.sh
# Run it. ~6 min on Unraid hardware (native amd64, no QEMU). # Run it. ~6 min on Unraid hardware (native amd64, no QEMU).
chmod +x /tmp/pxeforge-publish.sh chmod +x /tmp/openpxe-publish.sh
GITEA_TOKEN=$GITEA_TOKEN /tmp/pxeforge-publish.sh GITEA_TOKEN=$GITEA_TOKEN /tmp/openpxe-publish.sh
``` ```
What it does: What it does:
@@ -47,17 +47,17 @@ After it finishes, in Unraid → Docker → Add Container, set:
| Field | Value | | Field | Value |
|------------|-------------------------------------------------| |------------|-------------------------------------------------|
| Repository | `gitea.milesward.dev/mward4/pxeforge:0.1.0` | | Repository | `gitea.milesward.dev/mward4/openpxe:0.1.0` |
| Network | `host` | | Network | `host` |
| Extra args | `--cap-add=NET_BIND_SERVICE` | | Extra args | `--cap-add=NET_BIND_SERVICE` |
Volume mounts (paths inside container in **bold**): Volume mounts (paths inside container in **bold**):
- **`/var/lib/pxeforge/isos`** ↔ `/mnt/user/appdata/pxeforge/isos` - **`/var/lib/openpxe/isos`** ↔ `/mnt/user/appdata/openpxe/isos`
- **`/var/lib/pxeforge/work`** ↔ `/mnt/user/appdata/pxeforge/work` - **`/var/lib/openpxe/work`** ↔ `/mnt/user/appdata/openpxe/work`
- **`/var/lib/pxeforge/smb`** ↔ `/mnt/user/appdata/pxeforge/smb` - **`/var/lib/openpxe/smb`** ↔ `/mnt/user/appdata/openpxe/smb`
Or skip the manual UI by dropping `pxeforge.xml` (in this directory) Or skip the manual UI by dropping `openpxe.xml` (in this directory)
into `/boot/config/plugins/dockerMan/templates-user/` and Unraid will into `/boot/config/plugins/dockerMan/templates-user/` and Unraid will
list it as a one-click template. list it as a one-click template.
@@ -70,15 +70,15 @@ Skip the registry entirely. Useful for "hack on it locally" iterations.
```bash ```bash
ssh root@unraid ssh root@unraid
cd /mnt/user/appdata cd /mnt/user/appdata
git clone http://localhost:3000/mward4/PXEForge.git pxeforge-src git clone http://localhost:3000/mward4/OpenPXE.git openpxe-src
cd pxeforge-src cd openpxe-src
bash scripts/fetch-ipxe.sh bash scripts/fetch-ipxe.sh
docker compose -f docker-compose.yml up -d --build pxeforge docker compose -f docker-compose.yml up -d --build openpxe
``` ```
The bundled `docker-compose.yml` already wires host networking, the The bundled `docker-compose.yml` already wires host networking, the
right cap_add, and bind-mounts to `./data/`. Edit those bind-mount right cap_add, and bind-mounts to `./data/`. Edit those bind-mount
paths if you want them under `/mnt/user/appdata/pxeforge/`. paths if you want them under `/mnt/user/appdata/openpxe/`.
## Path C — `docker load` from a tarball I built off-box ## Path C — `docker load` from a tarball I built off-box
@@ -88,14 +88,14 @@ then:
```bash ```bash
# On the build host # On the build host
docker save pxeforge:0.1.0 | gzip > pxeforge-0.1.0.tar.gz docker save openpxe:0.1.0 | gzip > openpxe-0.1.0.tar.gz
# Transfer (rsync / scp / SMB / ZFS-replicate / sneakernet) # Transfer (rsync / scp / SMB / ZFS-replicate / sneakernet)
scp pxeforge-0.1.0.tar.gz root@unraid:/tmp/ scp openpxe-0.1.0.tar.gz root@unraid:/tmp/
# On Unraid # On Unraid
gunzip -c /tmp/pxeforge-0.1.0.tar.gz | docker load gunzip -c /tmp/openpxe-0.1.0.tar.gz | docker load
docker tag pxeforge:0.1.0 gitea.milesward.dev/mward4/pxeforge:0.1.0 docker tag openpxe:0.1.0 gitea.milesward.dev/mward4/openpxe:0.1.0
``` ```
If you want it pullable by tag from other Unraid templates, push to 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 ## Common gotchas
- **DHCP collision.** Don't run two PXE _proxies_ on the same broadcast - **DHCP collision.** Don't run two PXE _proxies_ on the same broadcast
domain. PXEForge runs in proxy mode and never offers IP leases, so domain. OpenPXE runs in proxy mode and never offers IP leases, so
it coexists with whatever DHCP server is already on the network — it coexists with whatever DHCP server is already on the network —
but two proxies racing each other will whichever-wins at random. but two proxies racing each other will whichever-wins at random.
- **Host networking only.** Bridge mode containers don't see broadcast - **Host networking only.** Bridge mode containers don't see broadcast
DHCP. There's no working bridge-mode config for a PXE server. DHCP. There's no working bridge-mode config for a PXE server.
- **Permissions on `/mnt/user/appdata/pxeforge`.** The container runs - **Permissions on `/mnt/user/appdata/openpxe`.** The container runs
as uid 10001 by default. The entrypoint chowns the bind mounts to as uid 10001 by default. The entrypoint chowns the bind mounts to
10001 on first start, but only if the container itself has root — 10001 on first start, but only if the container itself has root —
`--user=root` isn't needed; the multi-stage Dockerfile starts as `--user=root` isn't needed; the multi-stage Dockerfile starts as
root, fixes perms, then drops to pxeforge via gosu. root, fixes perms, then drops to openpxe via gosu.
- **NFS mounts in the Storage tab.** Mounting NFS inside the container - **NFS mounts in the Storage tab.** Mounting NFS inside the container
needs `CAP_SYS_ADMIN`. To enable, add `--cap-add=SYS_ADMIN` to the needs `CAP_SYS_ADMIN`. To enable, add `--cap-add=SYS_ADMIN` to the
Unraid template's "Extra args" — but understand that's a meaningful Unraid template's "Extra args" — but understand that's a meaningful
@@ -1,12 +1,12 @@
<?xml version="1.0"?> <?xml version="1.0"?>
<!-- <!--
Unraid Docker template for PXEForge. Unraid Docker template for OpenPXE.
Drop this file into /boot/config/plugins/dockerMan/templates-user/ Drop this file into /boot/config/plugins/dockerMan/templates-user/
on your Unraid box (or import via the Docker tab → "Add Container" → on your Unraid box (or import via the Docker tab → "Add Container" →
"Template Repositories" if you publish it on a Gitea raw URL). "Template Repositories" if you publish it on a Gitea raw URL).
IMPORTANT: PXEForge needs host networking for DHCP/TFTP raw broadcasts. IMPORTANT: OpenPXE needs host networking for DHCP/TFTP raw broadcasts.
Bridge mode will NOT work — clients can't see broadcast DHCP from a Bridge mode will NOT work — clients can't see broadcast DHCP from a
bridged container. The template forces NetworkType=host below. bridged container. The template forces NetworkType=host below.
@@ -21,20 +21,20 @@
Web UI: http://<unraid-ip>/ (port 80) Web UI: http://<unraid-ip>/ (port 80)
--> -->
<Container version="2"> <Container version="2">
<Name>PXEForge</Name> <Name>OpenPXE</Name>
<Repository>gitea.milesward.dev/mward4/pxeforge:latest</Repository> <Repository>gitea.milesward.dev/mward4/openpxe:latest</Repository>
<Registry>https://gitea.milesward.dev/mward4/-/packages/container/pxeforge</Registry> <Registry>https://gitea.milesward.dev/mward4/-/packages/container/openpxe</Registry>
<Network>host</Network> <Network>host</Network>
<MyIP/> <MyIP/>
<Shell>sh</Shell> <Shell>sh</Shell>
<Privileged>false</Privileged> <Privileged>false</Privileged>
<Support>https://gitea.milesward.dev/mward4/PXEForge/issues</Support> <Support>https://gitea.milesward.dev/mward4/OpenPXE/issues</Support>
<Project>https://gitea.milesward.dev/mward4/PXEForge</Project> <Project>https://gitea.milesward.dev/mward4/OpenPXE</Project>
<Overview> <Overview>
Air-gapped network PXE boot server. Container-native Rust Air-gapped network PXE boot server. Container-native Rust
implementation — DHCP proxy + TFTP + iPXE chainload + HTTP ISO implementation — DHCP proxy + TFTP + iPXE chainload + HTTP ISO
streaming, all in one process. Web UI for ISO upload, NFS share streaming, all in one process. Web UI for ISO upload, NFS share
mounting, and Gated Deployment ("horse-race" simultaneous launch mounting, and Queued Deployment ("horse-race" simultaneous launch
of one ISO across many waiting clients). of one ISO across many waiting clients).
NEVER touches the client OS trust store: no test-signed drivers, NEVER touches the client OS trust store: no test-signed drivers,
@@ -44,7 +44,7 @@
<Category>Network:Other Network:Management</Category> <Category>Network:Other Network:Management</Category>
<WebUI>http://[IP]/</WebUI> <WebUI>http://[IP]/</WebUI>
<TemplateURL/> <TemplateURL/>
<Icon>https://gitea.milesward.dev/mward4/PXEForge/raw/branch/main/crates/webui/src/logo.svg</Icon> <Icon>https://gitea.milesward.dev/mward4/OpenPXE/raw/branch/main/crates/webui/src/logo.svg</Icon>
<ExtraParams>--cap-add=NET_BIND_SERVICE</ExtraParams> <ExtraParams>--cap-add=NET_BIND_SERVICE</ExtraParams>
<PostArgs/> <PostArgs/>
<CPUset/> <CPUset/>
@@ -53,23 +53,23 @@
<DonateLink/> <DonateLink/>
<Requires> <Requires>
Host networking. Unraid&#39;s built-in DHCP server (if any) must Host networking. Unraid&#39;s built-in DHCP server (if any) must
not collide with a network that already has DHCP — PXEForge runs not collide with a network that already has DHCP — OpenPXE runs
in proxy mode and coexists, but only one DHCP _proxy_ should reply in proxy mode and coexists, but only one DHCP _proxy_ should reply
per broadcast domain. per broadcast domain.
</Requires> </Requires>
<Config Name="ISOs" Target="/var/lib/pxeforge/isos" Default="/mnt/user/appdata/pxeforge/isos" <Config Name="ISOs" Target="/var/lib/openpxe/isos" Default="/mnt/user/appdata/openpxe/isos"
Mode="rw" Description="Where uploaded and seeded .iso files live." Mode="rw" Description="Where uploaded and seeded .iso files live."
Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/pxeforge/isos</Config> Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/openpxe/isos</Config>
<Config Name="Work dir" Target="/var/lib/pxeforge/work" Default="/mnt/user/appdata/pxeforge/work" <Config Name="Work dir" Target="/var/lib/openpxe/work" Default="/mnt/user/appdata/openpxe/work"
Mode="rw" Description="Settings, NFS state, and runtime scratch. Persisted across restarts." Mode="rw" Description="Settings, NFS state, and runtime scratch. Persisted across restarts."
Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/pxeforge/work</Config> Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/openpxe/work</Config>
<Config Name="SMB share root" Target="/var/lib/pxeforge/smb" Default="/mnt/user/appdata/pxeforge/smb" <Config Name="SMB share root" Target="/var/lib/openpxe/smb" Default="/mnt/user/appdata/openpxe/smb"
Mode="rw" Description="Where extracted Windows install media lives. Only used when Windows toggle is on." 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/pxeforge/smb</Config> Type="Path" Display="advanced" Required="false" Mask="false">/mnt/user/appdata/openpxe/smb</Config>
<Config Name="Public IP" Target="PXEFORGE_PUBLIC_IP" Default="" <Config Name="Public IP" Target="OPENPXE_PUBLIC_IP" Default=""
Mode="" Description="IP advertised to PXE clients. Leave blank to auto-detect; set explicitly on multi-homed Unraid hosts." 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> Type="Variable" Display="always" Required="false" Mask="false"></Config>
<Config Name="Log filter" Target="PXEFORGE_LOG" Default="info,pxeforge=debug" <Config Name="Log filter" Target="OPENPXE_LOG" Default="info,openpxe=debug"
Mode="" Description="tracing-subscriber EnvFilter expression." Mode="" Description="tracing-subscriber EnvFilter expression."
Type="Variable" Display="advanced" Required="false" Mask="false">info,pxeforge=debug</Config> Type="Variable" Display="advanced" Required="false" Mask="false">info,openpxe=debug</Config>
</Container> </Container>
+22 -22
View File
@@ -5,13 +5,13 @@
# 1. Local MVP test — host network, proxy-DHCP off (don't fight your # 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: # existing DHCP server on the LAN), TFTP + HTTP exposed on the host:
# #
# docker compose up pxeforge-dev # docker compose up openpxe-dev
# #
# 2. Real PXE deployment — host network, proxy-DHCP on, runs on a box # 2. Real PXE deployment — host network, proxy-DHCP on, runs on a box
# plugged into the PXE network: # plugged into the PXE network:
# #
# # First set PXEFORGE_PUBLIC_IP to this host's LAN address in .env # # First set OPENPXE_PUBLIC_IP to this host's LAN address in .env
# docker compose up pxeforge # docker compose up openpxe
# #
# On Linux hosts, `network_mode: host` gives the container direct access to # On Linux hosts, `network_mode: host` gives the container direct access to
# the physical NIC — required for DHCP proxy because CNI overlays and Docker # 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 # 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 # 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 `pxeforge-dev` # VM, not your Mac. Proxy-DHCP is not feasible on macOS; use `openpxe-dev`
# with published ports and set DHCP-MODE=disabled. # with published ports and set DHCP-MODE=disabled.
services: services:
# Real PXE deployment (Linux hosts). # Real PXE deployment (Linux hosts).
pxeforge: openpxe:
image: pxeforge:0.1.0 image: openpxe:0.1.0
build: build:
context: . context: .
dockerfile: deploy/docker/Dockerfile dockerfile: deploy/docker/Dockerfile
@@ -34,33 +34,33 @@ services:
environment: environment:
# REQUIRED on multi-homed hosts. Set to this machine's LAN IP so the # REQUIRED on multi-homed hosts. Set to this machine's LAN IP so the
# advertised iPXE URLs actually resolve from the PXE clients. Without # advertised iPXE URLs actually resolve from the PXE clients. Without
# this, PXEForge will refuse to start rather than advertise a # this, OpenPXE will refuse to start rather than advertise a
# loopback address that can't be reached. # loopback address that can't be reached.
PXEFORGE_PUBLIC_IP: ${PXEFORGE_PUBLIC_IP:?set this to the host LAN IP} OPENPXE_PUBLIC_IP: ${OPENPXE_PUBLIC_IP:?set this to the host LAN IP}
PXEFORGE_DHCP_MODE: proxy OPENPXE_DHCP_MODE: proxy
PXEFORGE_LOG: info OPENPXE_LOG: info
volumes: volumes:
- ./data/isos:/var/lib/pxeforge/isos - ./data/isos:/var/lib/openpxe/isos
- ./data/work:/var/lib/pxeforge/work - ./data/work:/var/lib/openpxe/work
# Dev / MVP container: published ports, DHCP disabled, HTTP on 8080. # Dev / MVP container: published ports, DHCP disabled, HTTP on 8080.
# Use this on laptops where you want to curl the API or UI without # Use this on laptops where you want to curl the API or UI without
# running an actual PXE chain. # running an actual PXE chain.
pxeforge-dev: openpxe-dev:
image: pxeforge:0.1.0 image: openpxe:0.1.0
build: build:
context: . context: .
dockerfile: deploy/docker/Dockerfile dockerfile: deploy/docker/Dockerfile
environment: environment:
PXEFORGE_PUBLIC_IP: ${PXEFORGE_PUBLIC_IP:-127.0.0.1} OPENPXE_PUBLIC_IP: ${OPENPXE_PUBLIC_IP:-127.0.0.1}
PXEFORGE_DHCP_MODE: disabled OPENPXE_DHCP_MODE: disabled
PXEFORGE_HTTP_PORT: "8080" OPENPXE_HTTP_PORT: "8080"
PXEFORGE_TFTP_PORT: "6969" OPENPXE_TFTP_PORT: "6969"
PXEFORGE_DHCP_PORT: "6767" OPENPXE_DHCP_PORT: "6767"
PXEFORGE_LOG: info,pxeforge=debug OPENPXE_LOG: info,openpxe=debug
ports: ports:
- "8080:8080/tcp" - "8080:8080/tcp"
- "6969:6969/udp" - "6969:6969/udp"
volumes: volumes:
- ./data/isos:/var/lib/pxeforge/isos - ./data/isos:/var/lib/openpxe/isos
- ./data/work:/var/lib/pxeforge/work - ./data/work:/var/lib/openpxe/work
+156
View File
@@ -0,0 +1,156 @@
# Phase 6 — recommendations
The v0.2.0 cut leaves OpenPXE in a state where the entire protocol stack
and operator UI are exercised by 66 automated tests, the container is
multi-arch buildable, and the image ships at ~97 MB. What's left before
this looks and feels like a 1.0 product is mostly **real-hardware
validation** plus a small batch of features that can only sensibly be
designed once we've watched real machines image.
This doc is a punch list, ordered by what I'd do first if I had a week.
## Tier 1 — must-do before we call anything "stable"
### 1. Real-hardware validation matrix
We have CI tests for every protocol leg, but no end-to-end PXE on real
firmware. Build a small matrix:
| client | firmware | OS family | pass criteria |
|-------------------------------------|-----------|------------|---------------------------|
| any 10-y-old mini-PC | Legacy BIOS | Ubuntu Server 24.04 | gets to GRUB / installer |
| Intel NUC / similar | UEFI x64 | Windows 11 | reaches "where do you want to install" |
| Raspberry Pi 4 | UEFI ARM64 | Raspberry Pi OS | gets to login prompt |
| Dell / HP business laptop | UEFI x64 | Fedora | one of: kernel boot or wimboot |
Add a `docs/HARDWARE_VALIDATION.md` checklist that records what worked,
firmware versions, and any quirks. Anything weird gets a regression
test in the relevant crate.
### 2. Boot menu hotkey + UI accessibility audit
The iPXE menu has number-key + letter hotkeys but no documentation on
what they map to. Generate a printable cheat-sheet from
`crates/http-api/src/ipxe_script.rs` so operators don't have to read
the source. Run a screen-reader pass over the web UI — most of it
should be fine since we're mostly tables + form labels, but the
Terminal pane and the SSE log output need explicit `aria-live`
regions.
### 3. Boot.wim re-patch detection
Bootimus v0.1.62's "fingerprint of patched inputs + Save & Re-patch"
pattern is a small but high-value feature: when an operator changes
the SMB host override or upgrades wimboot, the existing patched
boot.wim is silently stale. We should:
- Hash the inputs (smb_host, smb_share, startnet.cmd content,
wimboot binary digest) into the IsoMeta;
- Surface a "needs re-patch" warning on the Storage tab when the
hash drifts;
- Add a "Re-patch SMB" button that re-runs the WimPatcher.
## Tier 2 — features that round out pre-beta
### 4. Auto-install file library
iVentoy and Bootimus both support attaching `autounattend.xml` /
`preseed.cfg` / `kickstart.cfg` to an image. The mechanics are
straightforward: store files under `<work_dir>/autoinstall/<distro>/`,
expose CRUD via `/api/autoinstall-files`, and modify the WimPatcher
+ Linux kernel cmdline to fetch + apply the right file. Placeholders
worth supporting (Bootimus pattern): `{{MAC}}`, `{{HOSTNAME}}`,
`{{IP}}`, `{{SERVER_ADDR}}`, `{{IMAGE_FILENAME}}`, substituted
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
do the rest. Implementation is small (`udp/9` broadcast, magic packet
construction) but it makes the bound-host workflow feel instant.
### 6. Distro profile manifest
Today, distro detection lives as Rust match arms in `introspect.rs`
and the kernel cmdline templates live in `store.rs`. Bootimus extracts
this into a JSON manifest that ships embedded in the binary AND is
overridable by the operator at runtime — so a new distro can be added
without rebuilding the container. Worth porting; it'd let community
contributions land as PRs to a single JSON file.
### 7. Syslog receiver
`smee` ships one. The use case: WinPE / Linux installers can be
configured to syslog over the network to the PXE server; if we have
an endpoint and a place in the UI to view per-client diagnostics,
post-mortem on a failed install gets dramatically easier.
### 8. UEFI HTTP Boot validation
Option 60 = `HTTPClient` is wired up in `decide()` already, but
we've never tested it on real firmware. Some Dell + Lenovo UEFIs
prefer it over PXE-via-TFTP. A quick check on a real machine
(disable TFTP boot in firmware, force HTTP boot) and a regression
test would be nice.
## Tier 3 — bigger lifts, only if there's demand
### 9. Pure-Rust SMB server
`smbd` from Samba is ~80 MB of the runtime image. There are pure-Rust
SMB2 server crates (`smbd-server`, `smb-rs`) of varying maturity.
Replacing the dep would slim the image by ~40% and remove the
`CAP_SYS_ADMIN` requirement for SMB. Worth a spike, not necessarily
landable in Phase 6.
### 10. IPv6 / DHCPv6
PXE-over-IPv6 is real (RFC 5970). Some sites are v6-only. Worth
implementing once we know we have one. Until then, IPv4-only is the
right default — flipping the bit on v6 without v6 testing is asking
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.
For HA we'd need to:
- Externalize the deployment 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;
- Document the L2 collision domain story.
This is a large lift and should only happen if someone's actually
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
a spec; the work is small once we have a Pi to test on.
## What I'd skip
- **A custom DHCP server (not proxy).** The proxy mode is the right
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
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
translations are LLM-generated. Skip until we have real users
asking for non-English.
## Quick wins (could land in a single afternoon)
- 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.
- 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
+ architecture.md (e.g. `cargo xtask runbook`).
+101 -27
View File
@@ -1,4 +1,4 @@
# PXEForge architecture # OpenPXE architecture
## Protocol stack ## Protocol stack
@@ -8,7 +8,7 @@ Client firmware PXE ROM
│ DHCPDISCOVER (UDP/67 broadcast, option 60 "PXEClient", option 93 arch) │ DHCPDISCOVER (UDP/67 broadcast, option 60 "PXEClient", option 93 arch)
┌─────────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────────┐
PXEForge OpenPXE
│ │ │ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ DHCP proxy │ │ TFTP server │ │ HTTP server (axum) │ │ │ │ DHCP proxy │ │ TFTP server │ │ HTTP server (axum) │ │
@@ -33,7 +33,7 @@ Client runs iPXE
│ DHCPDISCOVER with option 77 "iPXE" │ DHCPDISCOVER with option 77 "iPXE"
PXEForge sees user-class "iPXE" → replies with HTTP URL: /boot.ipxe OpenPXE sees user-class "iPXE" → replies with HTTP URL: /boot.ipxe
│ HTTP GET /boot.ipxe (iPXE menu, auto-generated from IsoStore) │ 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 | | Crate | Responsibility |
|---------------------|-------------------------------------------------------------------| |---------------------|-------------------------------------------------------------------|
| `pxeforge-core` | Shared types: `Config`, `ClientArch`, `FirmwareClass`, `ClientRegistry` | | `openpxe-core` | Shared types: `Config`, `ClientArch`, `FirmwareClass`, `ClientRegistry` |
| `pxeforge-ipxe-assets` | Embeds bundled iPXE binaries via `rust-embed` | | `openpxe-ipxe-assets` | Embeds bundled iPXE binaries via `rust-embed` |
| `pxeforge-iso-store` | On-disk ISO store, introspection, boot-entry generation | | `openpxe-iso-store` | On-disk ISO store, introspection, boot-entry generation |
| `pxeforge-dhcp-proxy` | UDP listener + `dhcproto` reply builder; pure `decide()` unit-testable | | `openpxe-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 | | `openpxe-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 | | `openpxe-http-api` | `axum` router: web UI, API, iPXE script generation, ISO streaming |
| `pxeforge-webui` | Single `index.html` served as static string | | `openpxe-webui` | Single `index.html` served as static string |
| `pxeforge` (bin) | Wires everything together, runs the three servers concurrently | | `openpxe` (bin) | Wires everything together, runs the three servers concurrently |
## Key decisions and why ## Key decisions and why
@@ -75,11 +75,11 @@ so plain `SOCK_DGRAM` is enough.
1. Firmware PXE ROM sends DHCPDISCOVER with option 60 = `PXEClient`, 1. Firmware PXE ROM sends DHCPDISCOVER with option 60 = `PXEClient`,
option 93 = arch. option 93 = arch.
2. PXEForge replies with TFTP server + arch-specific iPXE binary 2. OpenPXE replies with TFTP server + arch-specific iPXE binary
(`undionly.kpxe` for Legacy BIOS, `snponly.efi` for x86_64 UEFI, etc.). (`undionly.kpxe` for Legacy BIOS, `snponly.efi` for x86_64 UEFI, etc.).
3. Client TFTPs the iPXE binary and runs it. 3. Client TFTPs the iPXE binary and runs it.
4. iPXE does its own DHCP, setting option 77 (user-class) to `iPXE`. 4. iPXE does its own DHCP, setting option 77 (user-class) to `iPXE`.
5. PXEForge detects the user-class and this time replies with an HTTP URL 5. OpenPXE detects the user-class and this time replies with an HTTP URL
in option 67 pointing at `/boot.ipxe`. in option 67 pointing at `/boot.ipxe`.
6. iPXE fetches and executes that script, which chains the selected OS. 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` - instruct users to enable `bcdedit /set testsigning on`
- install any certificate into the target's root/trust store - install any certificate into the target's root/trust store
iVentoy's `httpdisk.sys` approach broke this rule. PXEForge doesn't. iVentoy's `httpdisk.sys` approach broke this rule. OpenPXE doesn't.
### Linux ISO boot uses kernel+initrd extraction, not sanboot ### 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. root-owned" problem that breaks ISO upload on standard Docker hosts.
- `/healthz` and `/readyz` split from `/api/status` — readyz fails if no - `/healthz` and `/readyz` split from `/api/status` — readyz fails if no
iPXE binaries are bundled, giving K8s probes a real signal. iPXE binaries are bundled, giving K8s probes a real signal.
- Startup aborts with a clear error if `PXEFORGE_PUBLIC_IP` can't be - Startup aborts with a clear error if `OPENPXE_PUBLIC_IP` can't be
auto-detected (no more silent `127.0.0.1` advertisement). auto-detected (no more silent `127.0.0.1` advertisement).
- `scripts/fetch-ipxe.sh` fails non-zero if zero binaries download; the - `scripts/fetch-ipxe.sh` fails non-zero if zero binaries download; the
Dockerfile uses arch-scoped paths (`x86_64-efi/snponly.efi` etc.). Dockerfile uses arch-scoped paths (`x86_64-efi/snponly.efi` etc.).
**Windows boot plumbing** (new): **Windows boot plumbing** (new):
- `pxeforge-iso-store::smb::SmbManager` supervises `smbd` on the Windows - `openpxe-iso-store::smb::SmbManager` supervises `smbd` on the Windows
toggle: `start` → spawn + write `smb.conf`; `reconcile` → SIGHUP on toggle: `start` → spawn + write `smb.conf`; `reconcile` → SIGHUP on
share changes; `stop` → SIGTERM. `SmbState` surfaced to the UI for share changes; `stop` → SIGTERM. `SmbState` surfaced to the UI for
visibility. visibility.
@@ -177,32 +177,32 @@ release:
- ISO sizes in menu labels (`[ 4376 MB]`), iVentoy format. - ISO sizes in menu labels (`[ 4376 MB]`), iVentoy format.
- `Reboot Computer` + `Exit and continue BIOS boot` in Tools menu. - `Reboot Computer` + `Exit and continue BIOS boot` in Tools menu.
- Number-key hotkeys (1..9) on boot entries, letter hotkeys on tools. - Number-key hotkeys (1..9) on boot entries, letter hotkeys on tools.
- Clients tab cross-joins the gate queue so an operator sees "at gate #2" - Clients tab cross-joins the deployment queue so an operator sees "in queue #2"
or "assigned: ubuntu-linux" status inline. or "assigned: ubuntu-linux" status inline.
**Developer ergonomics** (new): **Developer ergonomics** (new):
- `pxeforge seed --from <path>` CLI to import ISOs from a directory. - `openpxe seed --from <path>` CLI to import ISOs from a directory.
Same pipeline as web upload (slug, sha256, introspection, boot-entry). Same pipeline as web upload (slug, sha256, introspection, boot-entry).
- `docker-compose.yml` with `pxeforge` (host network, real PXE) and - `docker-compose.yml` with `openpxe` (host network, real PXE) and
`pxeforge-dev` (published ports, DHCP disabled, for API testing). `openpxe-dev` (published ports, DHCP disabled, for API testing).
**API cleanliness**: **API cleanliness**:
- All timestamps now serialized as RFC 3339 strings (the `time` crate's - All timestamps now serialized as RFC 3339 strings (the `time` crate's
default 9-tuple broke browser `Date` parsing). default 9-tuple broke browser `Date` parsing).
- Gate poll retains assignment until the operator releases it; if the - Queue poll retains assignment until the operator releases it; if the
client's chain fails, it reuses the assignment instead of falling back client's chain fails, it reuses the assignment instead of falling back
to the menu. to the menu.
## Phase 4 — UI restructure + remote storage ## Phase 4 — UI restructure + remote storage
The web UI was rebuilt around six tabs (Dashboard / Network / Forge Gate / The web UI was rebuilt around six tabs (Dashboard / Network / Queue /
Storage / Terminal / About) inspired by the iVentoy layout the user Storage / Terminal / About) inspired by the iVentoy layout the user
attached and Netbox Labs's compact-card pattern. The old hierarchical attached and Netbox Labs's compact-card pattern. The old hierarchical
"Monitoring / Content / Configuration" sidebar grouping is gone — every "Monitoring / Content / Configuration" sidebar grouping is gone — every
tab is one click from the brand bar. tab is one click from the brand bar.
**NFS share manager** (`crates/iso-store/src/nfs.rs`): **NFS share manager** (`crates/iso-store/src/nfs.rs`):
- Operators add a remote share via Storage → NFS shares; PXEForge mounts - Operators add a remote share via Storage → NFS shares; OpenPXE mounts
it under `<work_dir>/nfs/<id>/` and walks it for `*.iso` files. it under `<work_dir>/nfs/<id>/` and walks it for `*.iso` files.
- Each ISO found is registered with `IsoStore::register_external` using - Each ISO found is registered with `IsoStore::register_external` using
a new `IsoSource::Nfs { mount_id, relative_path }` variant. The store 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 followed by live updates. Slow clients see a `lagged` event rather
than dropping the stream. than dropping the stream.
- `/api/terminal` accepts a single command line and dispatches to a - `/api/terminal` accepts a single command line and dispatches to a
whitelist (`status`, `isos`, `clients`, `gate {list,assign,release}`, whitelist (`status`, `isos`, `clients`, `queue {list,assign,release}`,
`nfs {list,mount,unmount,scan}`, `smb {status,start,stop,reload}`, `nfs {list,mount,unmount,scan}`, `smb {status,start,stop,reload}`,
`log {clear,tail}`). Output is mirrored onto the LogBus so reading the `log {clear,tail}`). Output is mirrored onto the LogBus so reading the
live tail tells the same story as scrolling the terminal pane. 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 read-only by design — silently changing the public IP on a hot UI
would break PXE for every client mid-boot. would break PXE for every client mid-boot.
- The only writable network field is `dns_server`, an optional - The only writable network field is `dns_server`, an optional
informational hint stored in `Settings`. PXEForge does not run a DNS informational hint stored in `Settings`. OpenPXE does not run a DNS
server; the field exists so operators don't have to dig out the server; the field exists so operators don't have to dig out the
upstream DNS at 3 AM. upstream DNS at 3 AM.
@@ -252,11 +252,74 @@ tab is one click from the brand bar.
warning. warning.
- Dashboard surfaces a "Images that won't boot" panel reusing the same - Dashboard surfaces a "Images that won't boot" panel reusing the same
predicate, so the operator sees the problem before they pick the ISO predicate, so the operator sees the problem before they pick the ISO
in the gate. in the queue.
- Streaming uploads are already in place via axum multipart; the v0.1.62 - Streaming uploads are already in place via axum multipart; the v0.1.62
fix to "502 on big upload" doesn't apply. fix to "502 on big upload" doesn't apply.
## What's deferred to Phase 5 ## Phase 5 — pre-beta hardening
**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``,
`_tools_menu`).
- Persisted to `<work_dir>/hosts.json`. Like `SettingsStore`, in-memory
is authoritative — disk corruption falls back to empty rather than
failing startup.
- 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`):
- Lock-free `AtomicU64`-backed counters + gauges. No `prometheus` /
`metrics-rs` dep — they bring a registry, runtime, and complexity
we don't need for a fixed set of metric families.
- 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,
NFS active mounts, uptime, build info.
- Exposed as plain Prometheus text at `/metrics`.
**Code cleanup pass**: clippy `--workspace --all-targets` is now
warning-free. Replaced `format!()`-into-`String` with
`std::fmt::Write::write!`, switched manual reverse comparators to
`Reverse`, fixed `map_or(false, …)``is_some_and`, and a handful of
other idiom fixes.
**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 anvil (`logo.svg`) and a SMIL-animated
`anvil-forge.svg` (rising sparks + pulsing underglow). Pure SVG —
no GIFs, no CSS keyframes for the sparks.
- "Forge progress" widget on the Dashboard and Queue: animated
anvil paired with a `linear-gradient(warn → accent)` progress bar
with a moving sheen. Goes idle (greyscale, no sheen) at zero
imaging load.
- Loader replaced "Loading…" text with the same anvil.
- Sidebar gains a **Hosts** tab.
**Windows boot validation**:
- New integration test synthesizes an ISO9660 with the `SOURCES\BOOT.WIM`
sentinel, uploads it, and 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, and
4. **no** trust-store strings appear: `bcdedit`, `testsigning`,
`certutil`, `httpdisk`, `test-signed` are all explicitly
forbidden in the rendered output.
- WinPE bootstrap (`startnet.cmd`) now picks up Bootimus v0.1.58
fixes: explicit `net start Workstation` before `net use`, surfaces
errors instead of blind retries.
**Test count**: 66 → up from 56 in v0.1.0.
## What's deferred to Phase 6
- Full ISO9660 + Joliet + Rock Ridge parser (current lookup is plain ISO9660 — Debian ISOs with Rock Ridge extensions may miss some paths). - Full ISO9660 + Joliet + Rock Ridge parser (current lookup is plain ISO9660 — Debian ISOs with Rock Ridge extensions may miss some paths).
- Real-hardware Windows boot validation (plumbing tested; no MS ISO pushed through the full pipeline yet). - Real-hardware Windows boot validation (plumbing tested; no MS ISO pushed through the full pipeline yet).
@@ -268,3 +331,14 @@ tab is one click from the brand bar.
- Pure-Rust SMB server (replace smbd) — slim image, no Samba. - Pure-Rust SMB server (replace smbd) — slim image, no Samba.
- Auto-install / autounattend file library (Bootimus v0.1.58 pattern). - Auto-install / autounattend file library (Bootimus v0.1.58 pattern).
- Per-client / per-group menus (Bootimus v0.1.16 pattern). - Per-client / per-group menus (Bootimus v0.1.16 pattern).
- Real-hardware integration: at minimum a Linux ISO booted on a real
BIOS box, a Windows ISO booted via wimboot on a real UEFI box, and a
Pi 4 booting from an NFS-mounted Raspberry Pi OS ISO.
- Distro profile manifest (Bootimus v0.1.27 pattern) — currently
introspection logic is hard-coded; could become data-driven so an
operator can add a new distro profile from the UI without rebuilding.
- Wake-on-LAN trigger (Bootimus v0.1.16 pattern) — power-on a host then
imaging starts unattended via a per-MAC binding.
- Syslog receiver (smee feature) — capture client-side install syslog
for diagnostic visibility.
- IPv6 PXE / DHCPv6 — currently IPv4 only.
+403
View File
@@ -0,0 +1,403 @@
# Runbook: Boot a Linux machine from an ISO over the network
End-to-end walkthrough: spin up OpenPXE, 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
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
client you want to boot. PXE is L2-broadcast — routed/VLANd
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
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
device).
If those dont hold, stop and read [troubleshooting.md](troubleshooting.md)
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
the host:
```bash
ip -4 -o addr show | awk '{print $2, $4}'
```
Pick the address on the interface that faces the PXE VLAN — for
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
> 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
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
docker run -d --name openpxe \
--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
```
Substitute your `OPENPXE_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).
### Verify its alive
```bash
curl -fsS http://10.0.0.5/healthz # → 200 ok
curl -fsS http://10.0.0.5/readyz # → 200 ready (iPXE binaries present)
curl -fsS http://10.0.0.5/api/status | jq .
```
If `/readyz` is **not** 200, your container is missing iPXE binaries.
Fix that before going further — clients have nothing to boot
otherwise. See [README — Container health probes](../README.md#container-health-probes).
### Check the listening ports
OpenPXE holds three privileged UDP/TCP ports. From another shell on
the host:
```bash
sudo ss -lnup | grep -E ':(67|69|4011)\b' # DHCP proxy + TFTP
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 —
two listeners on `:67` will fight.
---
## 2. Load the ISO
Two options. Pick one.
### 2a. Web UI upload (recommended for one-offs)
1. Open `http://10.0.0.5/` in a browser.
2. Sidebar → **Storage**.
3. Click **Upload ISO**, pick e.g. `ubuntu-24.04.1-live-server-amd64.iso`.
4. Wait for upload + introspection. The row turns into a card showing:
- Distro family (`debian_ubuntu`)
- Volume label
- Detected kernel/initrd paths (`/casper/vmlinuz`, `/casper/initrd`)
- 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 anvil on the Dashboard tab fires up while imaging is in
flight.
### 2b. Bulk seed from a directory (recommended for fresh deploys / CI)
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 \
--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
```
Each `*.iso` in `/seed` runs through the same upload pipeline as the
web UI: copy → introspection → boot-entry generation → metadata
sidecar. Re-running is idempotent.
### Confirm the ISO is registered
```bash
curl -fsS http://10.0.0.5/api/isos | jq '.[] | {id, name, family, size}'
```
You should see something like:
```json
{
"id": "ubuntu-24-04-1-live-server-amd64",
"name": "ubuntu-24.04.1-live-server-amd64.iso",
"family": "debian_ubuntu",
"size": 2748000000
}
```
The `id` is the **slug**. Remember it — youll bind a MAC to it in
the next step.
---
## 3. Find the target machines MAC address
You need the MAC of the **NIC that will PXE**, not the OSs
loopback or wifi.
### 3a. From the target itself (if its already running an OS)
```bash
ip -o link | awk '/ether/ {print $2, $17}' # Linux
```
Pick the line for the wired NIC plugged into the PXE VLAN.
### 3b. From the firmware (if its a fresh box)
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
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).
3. Dont pick anything. On your laptop:
```bash
curl -fsS http://10.0.0.5/api/clients | jq .
```
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.
---
## 4. Pin that machine to the Ubuntu ISO
This is the **per-MAC host binding**. With it set, the client wont
see the menu at all — it goes straight to the bound boot entry,
Tinkerbell-style.
### 4a. Via the web UI
1. Sidebar → **Hosts**.
2. **Add binding**:
- **MAC**: `aa:bb:cc:dd:ee:ff`
- **Target**: pick `ubuntu-24-04-1-live-server-amd64` from the dropdown.
- **Label**: free-form, e.g. `lab-rack3-node07`.
3. Save.
### 4b. Via the API
```bash
curl -fsS -X POST http://10.0.0.5/api/hosts \
-H 'content-type: application/json' \
-d '{
"mac": "aa:bb:cc:dd:ee:ff",
"target": "ubuntu-24-04-1-live-server-amd64",
"label": "lab-rack3-node07"
}' | jq .
```
The binding is persisted to `~/openpxe/work/hosts.json` and survives
container restart.
### Confirm
```bash
curl -fsS http://10.0.0.5/api/hosts | jq '.[] | select(.mac=="aa:bb:cc:dd:ee:ff")'
```
You should see your entry with `created_at` and `updated_at`
timestamps.
---
## 5. Trigger the network boot on the target
Now actually boot the machine.
### 5a. Boot order
In firmware setup, set the wired NIC as the **first** boot device
(or hold `F12` / `F9` / `Esc` — vendor-specific — to pick "Network
Boot" interactively).
### 5b. What you should see on the target screen
In order, with timing:
| Stage | Approximate duration | What appears |
|-------|---------------------:|--------------|
| Firmware DHCPDISCOVER | ~1 s | `Start PXE over IPv4` / `Station IP address …` |
| TFTP iPXE binary fetch | ~1 s | `TFTP… snponly.efi` (or `undionly.kpxe` for legacy BIOS) |
| 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-…` |
| 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 |
If everything works, youre looking at the Ubuntu Server installer
welcome screen end-to-end **without ever touching a USB stick**.
### 5c. Watch it from the server
In a third shell, tail the live log:
```bash
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
```
The **Terminal** tab in the web UI shows the same thing live, plus a
short whitelisted command palette (`status`, `clients`, `gate`,
`hosts`, `log`).
### 5d. Internet-side ISO sources
The runbook title says “via the internet” — the **client** itself
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).
- **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`.
OpenPXE itself never reaches out to the internet at boot time — all
client traffic stays on the LAN, served from the host.
---
## 6. After the install
Once Ubuntu has finished installing to the targets disk, you want
the next reboot to come up off the new local disk, **not** PXE
again. Two ways:
### 6a. One-shot — release the binding
```bash
curl -fsS -X DELETE http://10.0.0.5/api/hosts/aa:bb:cc:dd:ee:ff
```
Without a binding, the client either gets the menu (BIOS still set
to PXE first) or boots local disk normally.
### 6b. Permanent — pin to local disk
Re-bind to the reserved local-boot target:
```bash
curl -fsS -X POST http://10.0.0.5/api/hosts \
-H 'content-type: application/json' \
-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
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
Different scenario: you have **a rack of 30 servers** to image
identically, all at once. Dont bind 30 MACs by hand. Use the gate.
1. **Dont** create host bindings.
2. PXE-boot every machine. They land on the menu.
3. On each: select **Queued Deployment**. They get position #1, #2,
…, #30 and start long-polling.
4. In the UI: **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 — the “horse race gate” opens.
The animated anvil widget on the Dashboard runs while any client is
still in the kernel-fetch phase.
---
## Cheat sheet
| Goal | Command |
|------|---------|
| Health check | `curl http://$IP/healthz` |
| List ISOs | `curl http://$IP/api/isos \| jq .` |
| List clients seen | `curl http://$IP/api/clients \| jq .` |
| Bind MAC → ISO | `POST /api/hosts` with `{mac,target,label}` |
| Bind MAC → local disk | same with `target=_local` |
| 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` |
---
## Where to look when things break
- **Client gets `PXE-E53: No boot filename received`** — DHCP proxy
isnt replying. Check `:67` is bound (`ss -lnup`), check
`--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`
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
`/api/isos`, compare the SHA-256 to the vendors, re-upload.
- **Boot menu shows but the bound entry doesnt fire** — the binding
target slug doesnt match any ISO `id`. Recheck
`GET /api/hosts` against `GET /api/isos`. The binding falls back
to the menu on miss (by design — never lock a client out).
- **General confusion** — Terminal tab → `status`, then `log`. That
tells you what protocol stages have run and which havent.
For deeper protocol-level debugging, see
[docs/architecture.md](../docs/architecture.md).
+9 -9
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# build-and-publish-unraid.sh — one-shot: clone PXEForge, build the image, # build-and-publish-unraid.sh — one-shot: clone OpenPXE, build the image,
# push it to your local Gitea container registry. Run this ON the Unraid # 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 # 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 # 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 # GITEA_HOST default: localhost:3000 (use 192.168.1.49:3000 if
# you're on the LAN but not on the Unraid host) # you're on the LAN but not on the Unraid host)
# GITEA_OWNER default: mward4 # GITEA_OWNER default: mward4
# GITEA_REPO default: PXEForge # GITEA_REPO default: OpenPXE
# IMAGE_TAG default: 0.1.0 (also tagged :latest) # IMAGE_TAG default: 0.1.0 (also tagged :latest)
# PLATFORM default: linux/amd64 (Unraid is x86_64) # PLATFORM default: linux/amd64 (Unraid is x86_64)
# WORKDIR default: /tmp/pxeforge-build (deleted on success) # WORKDIR default: /tmp/openpxe-build (deleted on success)
# #
# What it does: # What it does:
# 1. git clone <gitea>/mward4/PXEForge.git into WORKDIR # 1. git clone <gitea>/mward4/OpenPXE.git into WORKDIR
# 2. fetch iPXE binaries (scripts/fetch-ipxe.sh) # 2. fetch iPXE binaries (scripts/fetch-ipxe.sh)
# 3. docker build deploy/docker/Dockerfile -> pxeforge:$TAG (and :latest) # 3. docker build deploy/docker/Dockerfile -> openpxe:$TAG (and :latest)
# 4. docker login to GITEA_HOST using the token # 4. docker login to GITEA_HOST using the token
# 5. docker push to <gitea>/<owner>/pxeforge:<tag> and :latest # 5. docker push to <gitea>/<owner>/openpxe:<tag> and :latest
# 6. docker logout, scrub creds, clean WORKDIR # 6. docker logout, scrub creds, clean WORKDIR
# #
# After this, on any Unraid Docker template, set: # After this, on any Unraid Docker template, set:
# Repository: <gitea>/mward4/pxeforge:0.1.0 (or :latest) # Repository: <gitea>/mward4/openpxe:0.1.0 (or :latest)
# Network: host (DHCP/TFTP need raw L2) # Network: host (DHCP/TFTP need raw L2)
set -euo pipefail set -euo pipefail
GITEA_HOST=${GITEA_HOST:-localhost:3000} GITEA_HOST=${GITEA_HOST:-localhost:3000}
GITEA_OWNER=${GITEA_OWNER:-mward4} GITEA_OWNER=${GITEA_OWNER:-mward4}
GITEA_REPO=${GITEA_REPO:-PXEForge} GITEA_REPO=${GITEA_REPO:-OpenPXE}
IMAGE_TAG=${IMAGE_TAG:-0.1.0} IMAGE_TAG=${IMAGE_TAG:-0.1.0}
PLATFORM=${PLATFORM:-linux/amd64} PLATFORM=${PLATFORM:-linux/amd64}
WORKDIR=${WORKDIR:-/tmp/pxeforge-build} WORKDIR=${WORKDIR:-/tmp/openpxe-build}
# Lowercase the image name — OCI distribution rejects uppercase paths. # Lowercase the image name — OCI distribution rejects uppercase paths.
IMAGE_NAME="$(printf '%s' "$GITEA_REPO" | tr '[:upper:]' '[:lower:]')" IMAGE_NAME="$(printf '%s' "$GITEA_REPO" | tr '[:upper:]' '[:lower:]')"
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Fetch prebuilt iPXE binaries from the official distribution at # Fetch prebuilt iPXE binaries from the official distribution at
# https://boot.ipxe.org/ and place them under assets/ipxe/ with the filenames # https://boot.ipxe.org/ and place them under assets/ipxe/ with the filenames
# PXEForge's arch mapping expects. # OpenPXE's arch mapping expects.
# #
# Why not build from source? # Why not build from source?
# - Building iPXE requires the toolchain + several megabytes of source, and # - Building iPXE requires the toolchain + several megabytes of source, and