Compare commits

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

## Naming changes

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

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

## Brand assets

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

## CSS rename

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

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

## Validation

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

## Migration notes for operators on v0.2.0

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

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

## New features

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

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

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

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

## Code cleanup pass

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

## Windows workflow validation

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

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

## Docs

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

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

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

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

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

## Phase 1 — protocol stack

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

## Phase 2 — UX + Windows

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

## Phase 3 — MVP hardening

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

## Phase 4 — UI restructure + remote storage

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

## Tests

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

cargo build --workspace and cargo clippy --workspace --all-targets
both finish clean (warnings only, no errors).
2026-04-29 02:47:00 -04:00
86 changed files with 2013 additions and 27361 deletions
+16
View File
@@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"Bash(cargo check *)",
"Bash(cargo build *)",
"Bash(cargo clippy *)",
"Bash(cargo fmt *)",
"Bash(cargo tree *)",
"Bash(cargo doc *)",
"Bash(cargo test --workspace --lib)",
"Bash(cargo test --workspace)",
"Bash(cargo --version)",
"Bash(rustc --version)"
]
}
}
+1 -3
View File
@@ -1,4 +1,5 @@
/target /target
Cargo.lock
data/isos/*.iso data/isos/*.iso
data/isos/*.partial data/isos/*.partial
data/isos/*.meta.json data/isos/*.meta.json
@@ -11,6 +12,3 @@ data/work/
.claude/settings.local.json .claude/settings.local.json
.claude/worktrees/ .claude/worktrees/
.claude/scheduled_tasks.lock .claude/scheduled_tasks.lock
# local editor / agent settings (not part of the project)
.claude/
Generated
-5815
View File
File diff suppressed because it is too large Load Diff
+13 -70
View File
@@ -12,108 +12,51 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.7.3" version = "0.3.0"
edition = "2021" edition = "2021"
rust-version = "1.95" rust-version = "1.80"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
repository = "https://gitea.milesward.dev/mward4/OpenPXE" repository = "https://gitea.milesward.dev/mward4/OpenPXE"
authors = ["OpenPXE contributors"] authors = ["OpenPXE contributors"]
[workspace.dependencies] [workspace.dependencies]
tokio = { version = "1.52", features = ["full"] } tokio = { version = "1.40", features = ["full"] }
tokio-util = { version = "0.7", features = ["io"] } tokio-util = { version = "0.7", features = ["io"] }
tokio-stream = { version = "0.1", features = ["sync"] } tokio-stream = { version = "0.1", features = ["sync"] }
futures = "0.3" futures = "0.3"
async-trait = "0.1" async-trait = "0.1"
# v0.6.2: dhcproto 0.15 drops the deprecated trust-dns-proto dependency dhcproto = "0.12"
# (replaced by hickory-proto) and carries three releases of DHCP option socket2 = { version = "0.5", features = ["all"] }
# coverage accumulated upstream — both directly relevant to the proxy core.
dhcproto = "0.15"
socket2 = { version = "0.6", features = ["all"] }
bytes = "1.7" bytes = "1.7"
nom = "7.1"
axum = { version = "0.8", features = ["macros", "multipart", "http2"] } axum = { version = "0.7", features = ["macros", "multipart", "http2"] }
tower = "0.5" tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] } tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] }
hyper = "1.9" hyper = "1.4"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
mime = "0.3"
mime_guess = "2.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
toml = "0.8" toml = "0.8"
# v0.5.4: layered config (TOML file + env). Pure-Rust, no C deps; keeps the
# static-musl build OpenSSL-free. Replaces the hand-rolled apply_env mapping.
figment = { version = "0.10", features = ["toml", "env"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
anyhow = "1.0" anyhow = "1.0"
thiserror = "2.0" thiserror = "1.0"
clap = { version = "4.5", features = ["derive", "env"] } clap = { version = "4.5", features = ["derive", "env"] }
uuid = { version = "1.10", features = ["v4", "serde"] } uuid = { version = "1.10", features = ["v4", "serde"] }
time = { version = "0.3", features = ["serde", "serde-human-readable", "formatting", "macros"] } time = { version = "0.3", features = ["serde", "serde-human-readable", "formatting", "macros"] }
# sha2 stays 0.10 deliberately: bergshamra-crypto requires ^0.10, and
# bumping to 0.11 would split the RustCrypto digest stack in the tree.
sha2 = "0.10" sha2 = "0.10"
hex = "0.4" hex = "0.4"
bcrypt = "0.19" once_cell = "1.19"
parking_lot = "0.12" parking_lot = "0.12"
rust-embed = { version = "8.5", features = ["include-exclude"] } rust-embed = { version = "8.5", features = ["include-exclude"] }
# v0.4.67: pure-Rust NFSv3 client. Replaces the (deleted-in-v0.4.65)
# kernel-mount NFS path with an in-process implementation that works
# in any container — no kernel modules, no CAP_SYS_ADMIN, no
# subprocess. Ships alongside the userspace SMB consumer; operators
# pick whichever protocol their NAS prefers.
nfs3_client = { version = "0.9", features = ["tokio"] }
nfs3_types = "0.5"
# v0.5.0: SMTP for webhook notifications (Slack/Teams/Discord go over
# plain HTTP via reqwest; email needs a real SMTP client). rustls TLS
# to match reqwest and stay musl-static-friendly — no OpenSSL.
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "builder", "hostname"] }
# v0.5.1: pure-Rust SAML 2.0 Service Provider. bergshamra does XML-DSig
# verification + exclusive c14n with RustCrypto (no OpenSSL/xmlsec/libxml2
# C deps), so the static musl binary stays OpenSSL-free — samael was
# rejected precisely because it hard-requires OpenSSL. We build the thin
# SP layer (AuthnRequest, metadata parse, SAMLResponse semantics) on top.
bergshamra = "0.5"
roxmltree = "0.21"
quick-xml = "0.40"
x509-parser = "0.18"
# flate2 default backend is miniz_oxide (pure Rust) — do NOT enable the
# zlib/zlib-ng C backends, which would break the musl-static build.
flate2 = "1.1"
base64 = "0.22"
# v0.5.5: pure-Rust SSH/SFTP client for reading remote ISO libraries
# over SFTP without a kernel mount.
#
# CRITICAL #1 — crypto backend: `default-features = false` +
# `features = ["ring"]`. russh's *default* backend is `aws-lc-rs`, which
# pulls `aws-lc-sys` (C code, fiddly under musl); the `ring` feature
# instead reuses `ring 0.17` — the exact crate+version already in the
# binary via rustls + bergshamra — so SFTP adds ZERO new C/crypto deps
# and the static-musl build stays OpenSSL-free.
#
# CRITICAL #2 — history: this was pinned to =0.55.0 from v0.5.5 until
# v0.6.3 because bergshamra-crypto pinned release-candidate RustCrypto
# crates that conflicted with the stable generation russh 0.56+ pulls.
# bergshamra 0.5 (2026-06) moved to the stable generation (pkcs8 0.11),
# lifting the pin. v0.6.3 bumps to 0.61+, which also closes a batch of
# RUSTSEC advisories reachable from the SFTP *client* path (unbounded
# allocations in packet parsing — CVE-2026-48110/-46702/-46673 et al.)
# and drops mlock on non-secret buffers (~21% SSH throughput upstream).
#
# SCP was deliberately rejected: the protocol is sequential-only (no
# random access → no HTTP Range, unlike SFTP/NFS) and the mature SCP
# crates wrap libssh2 (C + OpenSSL), which would break this build.
russh = { version = "0.61", default-features = false, features = ["ring"] }
russh-sftp = "2.3"
openpxe-core = { path = "crates/core" } openpxe-core = { path = "crates/core" }
openpxe-dhcp-proxy = { path = "crates/dhcp-proxy" } openpxe-dhcp-proxy = { path = "crates/dhcp-proxy" }
openpxe-tftp = { path = "crates/tftp" } openpxe-tftp = { path = "crates/tftp" }
+231 -200
View File
@@ -1,270 +1,301 @@
<p align="center"> # OpenPXE
<img src="docs/openpxe-logo.svg" alt="OpenPXE" width="104" height="104" />
</p>
<h1 align="center">OpenPXE</h1> Container-native PXE boot server. A Rust reimplementation of
[iVentoy (ventoy/PXE)](https://github.com/ventoy/PXE), designed from scratch
for Docker/OCI and OpenShift. Upload `.iso` files via the web UI; network
clients PXE-boot them.
<p align="center"> > **Status:** v0.2.0 / pre-beta. Phases 15 complete: full PXE stack,
<strong>Container-native network boot &amp; OS deployment — built in Rust.</strong> > Queued Deployment queue, NFS-share ISO sources, live tracing log + an
</p> > operator terminal, per-MAC host bindings (Tinkerbell-style),
> Prometheus `/metrics`, light/dark theme toggle, animated anvil
> imaging-progress widget. **66 tests passing**, clippy clean. Ready
> for real-hardware validation.
<p align="center"> ## Design non-negotiables
Drag in an ISO. PXE-boot and image an entire fleet from a browser.<br/>
No iPXE scripting. No <code>dnsmasq</code> + <code>tftpd</code> + Samba glue. No glibc. No garbage collector.
</p>
<p align="center"> 1. **Fully offline / air-gap deployable.** Zero CDN assets. Zero external
<img alt="release" src="https://img.shields.io/badge/release-v0.6.0-2874d7" /> HTTP calls from the server, the browser, or the generated iPXE scripts.
<img alt="license" src="https://img.shields.io/badge/license-MIT%20%7C%20Apache--2.0-59824f" /> Build the container once, run forever disconnected.
<img alt="rust" src="https://img.shields.io/badge/built%20with-Rust-fb8841?logo=rust&logoColor=white" /> 2. **iPXE is a backend implementation detail.** No `.ipxe` upload path, no
<img alt="container" src="https://img.shields.io/badge/container--native-OCI%20%C2%B7%20OpenShift-2496ED?logo=docker&logoColor=white" /> manual script editing, no iPXE terminology in the UI. Every knob in the
<img alt="binary" src="https://img.shields.io/badge/static-musl%20%C2%B7%20~18MB-330f1f" /> web UI maps to a specific script-generation behavior inside the binary.
</p> 3. **The client trust store is off-limits.** No test-signed drivers, no
`bcdedit /set testsigning on`, no certificates injected into WinPE or
the target OS.
--- ## What it does
OpenPXE turns bare-metal provisioning into a single container with a web UI. It's a 1. **DHCP proxy** (RFC 4578). Coexists with your existing DHCP server —
ground-up Rust reimplementation of [iVentoy (ventoy/PXE)](https://github.com/ventoy/PXE), never assigns IPs. Listens on UDP 67 + UDP 4011.
designed for Docker/OCI and OpenShift instead of a Windows desktop — so it drops onto 2. **TFTP server** (RFC 1350 + RFC 2347/2348/2349/7440 option negotiation)
an Unraid box, a Linux server, or a Kubernetes cluster and just runs. that serves architecture-specific iPXE binaries to firmware PXE ROMs.
3. **HTTP server** that serves the web UI, the generated iPXE boot scripts,
raw ISOs (with Range), and files inside ISOs without prior extraction.
4. **ISO introspection**: auto-detects the distro family and generates the
appropriate kernel+initrd or wimboot chain. No manual config.
5. **Hierarchical PXE menu** mirroring the Phase 2 spec:
```
Default > Boot from Local HDD
Installers > Linux Installers / Windows Installers
Tools > Utilities / OpenPXE Shell / Network Card Info
Queued Deployment
```
6. **Queued Deployment queue** — the "horse race" launch flow. A client that
selects *Queued Deployment* gets a numbered position and waits. The
operator picks an ISO in the web UI and fires it to every waiting
client simultaneously.
7. **Web UI** (Netbox-style): sidebar nav (Dashboard / Network / Forge
Gate / Storage / Hosts / Terminal / About), light + dark themes
(toggle top-right or press `T`), animated anvil "forge progress"
widget when devices are imaging. All assets served from the binary —
no external requests.
8. **Per-MAC host bindings.** Pin a MAC to a boot target and the client
skips the menu, chains straight through. 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
600s), the timeout action (stay / Local HDD / Queued Deployment), and
feature toggles like Windows ISO support. The iPXE scripts regenerate
on every request using current settings.
Upload `.iso` files (or point at a remote share), and any machine on the network boots ### Architectures supported on day one
them — Linux installers, live tools, or stock Windows setup — with **zero iPXE knowledge
required by the operator.**
> **Status — v0.6.0, late pre-beta.** The full PXE stack, web UI, remote ISO libraries | DHCP option 93 | Architecture | Binary served |
> (SMB/NFS/SFTP), Windows deployment, queued fleet rollout, SAML SSO, and Prometheus |----------------|-----------------|-------------------------|
> metrics are implemented and test-covered. The release checklist gates every tag on the | `0x0000` | Legacy x86 BIOS | `undionly.kpxe` |
> full test suite + `clippy`. Currently in real-hardware validation. | `0x0006` | IA32 UEFI | `snponly-i386.efi` |
| `0x0007`/`0x0009` | x86_64 UEFI | `snponly.efi` |
| `0x000B` | ARM64 UEFI | `snponly-arm64.efi` |
## Why OpenPXE UEFI firmware that sends `HTTPClient` in option 60 is handled too — we
skip TFTP and respond with an HTTP URL.
Standing up network boot the traditional way means hand-wiring `dnsmasq`, a TFTP daemon, ## Quick start — MVP container (recommended)
hand-written iPXE menu scripts, an HTTP server, and Samba — then keeping that fragile
stack alive, and discovering none of it containerizes cleanly (kernel-mount NFS, raw
sockets, `CAP_SYS_ADMIN`). iVentoy solved the UX beautifully, but it's a Windows GUI app.
OpenPXE collapses that whole stack into **one statically-linked binary in one container**:
- **A web UI does everything.** iPXE is an internal implementation detail — there is no
script upload, no `.ipxe` editing, no PXE jargon in the interface.
- **It runs anywhere a container runs.** No kernel modules, no privileged mode — proxy-mode
DHCP + `NET_BIND_SERVICE` is the entire requirement. Verified on Unraid, plain Docker,
and OpenShift's restricted SCC.
- **It's air-gap native.** Zero CDN assets, zero outbound calls from the server, browser,
or generated boot scripts. Build the image once, run it forever, disconnected.
## Highlights
#### Boot stack
- **DHCP proxy** (RFC 4578) that coexists with your existing DHCP — it never hands out IPs.
- **TFTP** (RFC 1350 + 2347/2348/2349/7440 option negotiation) serving arch-correct iPXE firmware.
- **HTTP** serving the UI, generated boot scripts, raw ISOs (with byte-range), and files
*inside* ISOs with no prior extraction.
- **Graphical iPXE boot menu** built from your uploads, with a PNG background and a clean
hierarchy — generated fresh on every request from current settings.
#### ISO management & remote libraries
- **Drag-and-drop chunked uploads** that don't 502 on multi-GB images.
- **Automatic introspection** — detects the distro family and generates the right
kernel+initrd or Windows `wimboot` chain. No manual config.
- **Remote ISO libraries, streamed on demand** (no local cache) over **SMB, NFS, or SFTP**
see the table below.
#### Fleet deployment
- **Queued Deployment** — clients join a queue and wait; the operator fires one image at
every waiting machine simultaneously.
- **Per-MAC host bindings** — pin a MAC straight to a target (with optional auto hostname,
auto IP, and an unattended answer file); it skips the menu and chains through.
- **Unattended installs** — upload Kickstart / Preseed / Autoinstall / Windows answer files;
they're templated per-host (hostname / IP / MAC) and served only to booting clients.
- **Windows deployment** from a stock Microsoft ISO — **every binary the client runs stays
Microsoft-signed** (details below).
#### Operations & access
- **SAML 2.0 single sign-on** (pure-Rust SP, no OpenSSL/xmlsec) alongside local accounts.
- **Custom branding** — light / dark / PXE-client logos and favicon.
- **Notifications** — Slack / Teams / Discord webhooks and SMTP email on boot events.
- **Prometheus `/metrics`**, a built-in operator **terminal**, live tracing log, and
`/healthz` · `/readyz` probes.
- **Layered config** — defaults → TOML file → `OPENPXE_*` env, in that order.
## Built in Rust
Rust isn't a checkbox here — it's why OpenPXE deploys the way it does:
- **One static binary, ~18 MB.** Compiled to `x86_64-unknown-linux-musl` — no glibc, no
interpreter, no sidecar runtime. The runtime image is "binary + a few CLI tools."
- **No garbage collector, async throughout.** A Tokio runtime drives DHCP, TFTP, HTTP, and
many concurrent multi-GB ISO streams on a tiny, predictable memory footprint — it idles
near-zero and never GC-pauses mid-transfer.
- **Memory-safe by construction.** `unsafe` is **denied workspace-wide**; the only
exceptions are two small, individually-audited FFI calls (`statvfs` for disk usage and a
Samba `SIGHUP`).
- **OpenSSL-free, pure-Rust crypto.** TLS via `rustls`/`ring`; the SAML Service Provider
does XML-DSig verification with RustCrypto — no `xmlsec`, no `libxml2`, no C crypto to
CVE-patch. Even the SMB/NFS/SFTP clients avoid C libraries.
- **Sub-minute, reproducible container builds.** Cross-compiled with `cargo-zigbuild`
(zig as the linker) — a full image builds in well under a minute on a warm cache, with
no QEMU emulation.
## Quick start
### Run the container
```bash ```bash
# Build the self-contained image (iPXE binaries are fetched + built inside the Dockerfile). # 1. Pull bundled iPXE binaries (~2 MB, one-time).
docker build -f deploy/docker/Dockerfile -t openpxe:0.5.5 . ./scripts/fetch-ipxe.sh
# Run it on the box plugged into your PXE network. Host networking is required in # 2. Build the container image (~3 min first time).
# proxy mode so the container sees DHCPDISCOVER broadcasts; set PUBLIC_IP to this docker buildx build -f deploy/docker/Dockerfile -t openpxe:0.1.0 --load .
# host's LAN address so advertised boot URLs are reachable.
docker run -d --name openpxe --network host \ # 3. Run it on the box plugged into your PXE network. Set PUBLIC_IP to
# this host's LAN address so advertised iPXE URLs are reachable.
docker run -d --name openpxe \
--network host \
-e OPENPXE_PUBLIC_IP=10.0.0.5 \ -e OPENPXE_PUBLIC_IP=10.0.0.5 \
-e OPENPXE_DHCP_MODE=proxy \ -e OPENPXE_DHCP_MODE=proxy \
-v $PWD/data/isos:/var/lib/openpxe/isos \ -v $PWD/data/isos:/var/lib/openpxe/isos \
-v $PWD/data/work:/var/lib/openpxe/work \ -v $PWD/data/work:/var/lib/openpxe/work \
openpxe:0.5.5 openpxe:0.1.0
# 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
``` ```
> On macOS/Windows, Docker runs inside a Linux VM, so "host network" means the VM — use Host networking is required in proxy mode so the container sees DHCPDISCOVER
> the `openpxe-dev` service in `docker-compose.yml` for API-only testing on a laptop: broadcasts from the PXE VLAN. On macOS/Windows hosts Docker runs in a Linux
> `OPENPXE_PUBLIC_IP=127.0.0.1 docker compose up openpxe-dev`. VM, so "host" means the VM — use `openpxe-dev` in `docker-compose.yml` for
API-only testing on a laptop.
### Build from source ### Quick start — docker compose
```bash ```bash
./scripts/fetch-ipxe.sh # populate assets/ipxe/ (embedded at compile time) # MVP / API testing on a laptop (no DHCP, high ports):
cargo run --release # needs root or CAP_NET_BIND_SERVICE for :80/:69 OPENPXE_PUBLIC_IP=127.0.0.1 docker compose up openpxe-dev
# Real PXE deployment on a Linux host (host network, DHCP proxy on):
OPENPXE_PUBLIC_IP=10.0.0.5 docker compose up openpxe
``` ```
### Pre-seed ISOs from a directory ### Multi-arch build + push
For deploying to x86_64 servers, build both arches in one manifest:
```bash
# One-time: bootstrap a multi-arch builder.
docker buildx create --name openpxe-multi --driver docker-container --use
# Build + push both linux/amd64 and linux/arm64 under one tag.
docker buildx build --builder openpxe-multi \
--platform linux/amd64,linux/arm64 \
-t ghcr.io/YOUR-ORG/openpxe:0.1.0 \
--push \
-f deploy/docker/Dockerfile .
```
On an Apple Silicon host, the amd64 stage runs under QEMU emulation (~10-15 min for a cold cache). On a Linux x86_64 host, both arches build natively at normal speed. CI runners on GitHub Actions with `docker/build-push-action@v5` handle this cleanly.
### Build from source (no container)
```bash
./scripts/fetch-ipxe.sh
cargo run --release # needs NET_BIND_SERVICE or root for :80/:69
```
### Container health probes
| Endpoint | Purpose |
|-------------|---------------------------------------------------------------|
| `/healthz` | Liveness — HTTP stack alive. Always 200. |
| `/readyz` | Readiness — 200 only if iPXE binaries bundled + ISO dir OK. |
| `/api/status` | Full JSON status: versions, assets, counts, live settings, SMB state. |
### Pre-seeding ISOs from a directory
For CI, pre-baked homelab deployments, or a fresh PVC, the binary has a
`seed` subcommand that imports every `*.iso` from a host path through the
same pipeline the web UI uses (introspection + boot-entry generation):
```bash ```bash
docker run --rm \ docker run --rm \
-v /my/iso-library:/seed:ro \ -v /my/iso-library:/seed:ro \
-v openpxe-data:/var/lib/openpxe/isos \ -v openpxe-data:/var/lib/openpxe/isos \
-e OPENPXE_PUBLIC_IP=10.0.0.5 \ -e OPENPXE_PUBLIC_IP=10.0.0.5 \
openpxe:0.5.5 seed --from /seed # add --dry-run to preview openpxe:0.1.0 seed --from /seed
# Dry run first to see what would be imported:
docker run --rm -v /my/iso-library:/seed:ro openpxe:0.1.0 seed --from /seed --dry-run
``` ```
## Remote ISO libraries ### Environment overrides
Point OpenPXE at a NAS and boot ISOs straight off it — **read on demand, no local copy**, | Var | Default | Meaning |
so a 50-ISO library costs zero disk on the OpenPXE host. All three clients are userspace |------------------------|-----------------------------|----------------------------------------|
(no kernel mounts, no `CAP_SYS_ADMIN`); pick whichever your storage speaks. | `OPENPXE_HTTP_PORT` | `80` | Web UI + boot script HTTP port |
| `OPENPXE_TFTP_PORT` | `69` | TFTP port |
| `OPENPXE_DHCP_PORT` | `67` | DHCP server-side port |
| `OPENPXE_DHCP_MODE` | `proxy` | `proxy` or `disabled` |
| `OPENPXE_PUBLIC_IP` | auto-detect | Advertised IP for clients. Startup **fails** if unset and auto-detect returns loopback. |
| `OPENPXE_ISO_DIR` | `/var/lib/openpxe/isos` | Where uploaded ISOs live |
| `OPENPXE_WORK_DIR` | `/var/lib/openpxe/work` | Scratch + runtime settings |
| `OPENPXE_LOG` | `info,openpxe=debug` | `tracing` filter |
| Protocol | Implementation | Auth | HTTP Range¹ | ## What the boot menu looks like on a real client
|----------|----------------|------|-------------|
| **NFS** (v3) | Pure-Rust in-process client | Client-IP (server export list) | ✅ |
| **SFTP** (SSH) | Pure-Rust in-process client (`russh`) | Password **or** SSH key · host-key TOFU | ✅ |
| **SMB** / CIFS | Userspace `smbclient` | Guest or username/password | — |
¹ Range support lets clients seek into a multi-GB ISO without downloading what comes
before it — needed for kernel/initrd extraction and `httpdisk`-style boots. NFS and SFTP
expose explicit offsets; the SMB CLI streams sequentially, so SMB-sourced ISOs serve whole-file.
## Supported client architectures
| DHCP option 93 | Architecture | Firmware served |
|----------------|--------------|-----------------|
| `0x0000` | Legacy x86 BIOS | `undionly.kpxe` |
| `0x0006` | IA32 UEFI | `snponly-i386.efi` |
| `0x0007` / `0x0009` | x86_64 UEFI | `snponly.efi` |
| `0x000B` | ARM64 UEFI | `snponly-arm64.efi` |
UEFI firmware that advertises `HTTPClient` (option 60) skips TFTP entirely and is handed an HTTP URL.
## The boot menu, on a real client
``` ```
OpenPXE network boot menu OpenPXE - network boot menu
------------------------- Default ------------------------- ------------------------- Default -------------------------
Boot from Local HDD Boot from Local HDD
----------------------- Installers ------------------------ ----------------------- Installers -----------------------
Linux Installers > Linux Installers >
Windows Installers > (only if enabled in Settings) Windows Installers > (only if enabled in Settings)
-------------------------- Tools -------------------------- -------------------------- Tools --------------------------
Tools > Utilities / OpenPXE Shell / NIC Info / Reboot Tools > Utilities / Shell /
NIC Info / Reboot /
Exit and continue BIOS
---------------------- Queued Deployment ------------------ ---------------------- Queued Deployment ------------------
Queued Deployment (join queue) Queued Deployment (join queue)
``` ```
Linux/Windows submenus list images iVentoy-style with sizes: Linux/Windows submenus show file sizes iVentoy-style:
``` ```
OpenPXE Linux Installers OpenPXE - Linux Installers
[ 4699 MB] ubuntu-22.04.2-desktop-amd64 [ 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
[ 4699 MB] ubuntu-22.04.2-desktop-amd64
< Back to main menu < Back to main menu
``` ```
The entire hierarchy is generated from what you upload and toggle — iPXE never surfaces. iPXE never appears in the UI — the whole hierarchy above is generated from
ISOs you upload via drag-and-drop in the web UI plus toggles in Settings.
## Windows deployment
Enable **Windows ISO support** in Settings, then upload a **stock, unmodified** Microsoft ISO:
1. On upload, OpenPXE uses `wimlib-imagex` to inject exactly two plain-text files into the
WinPE image (`winpeshl.ini` + `startnet.cmd`) — no drivers, no certificates.
2. The container's Samba `smbd` serves the extracted install tree on `:445`.
3. The client chainloads `wimboot` → patched WinPE → Windows Setup running off the share.
**Every executable the client runs is stock Microsoft-signed.** OpenPXE never ships
drivers, never installs certificates into the client trust store, and never recommends
`bcdedit /set testsigning on`. The SMB approach is adapted (re-implemented, not copied)
from [Bootimus](https://github.com/garybowers/bootimus) (Apache-2.0). Port `445` must be
directly reachable from clients; Windows 10/11 client SKUs are the tested target.
## Configuration
All settings have defaults and layer **defaults → TOML (`--config` / `OPENPXE_CONFIG`) →
`OPENPXE_*` env**. The common knobs:
| Var | Default | Meaning |
|-----|---------|---------|
| `OPENPXE_PUBLIC_IP` | auto-detect | IP advertised to clients. **Startup fails** if unset and auto-detect yields loopback. |
| `OPENPXE_DHCP_MODE` | `proxy` | `proxy` or `disabled` |
| `OPENPXE_HTTP_PORT` | `80` | Web UI + boot-script HTTP port |
| `OPENPXE_TFTP_PORT` | `69` | TFTP port |
| `OPENPXE_DHCP_PORT` | `67` | DHCP server-side port |
| `OPENPXE_ISO_DIR` | `/var/lib/openpxe/isos` | Uploaded ISOs |
| `OPENPXE_WORK_DIR` | `/var/lib/openpxe/work` | Scratch, settings, share + branding state |
| `OPENPXE_LOG` | `info,openpxe=info` | `tracing` filter |
## OpenShift ## OpenShift
```bash ```bash
oc apply -f deploy/openshift/ oc apply -f deploy/openshift/
oc -n openpxe get all
oc -n openpxe get route openpxe -o jsonpath='{.spec.host}' oc -n openpxe get route openpxe -o jsonpath='{.spec.host}'
``` ```
The bundled `openpxe-scc` grants exactly `hostNetwork` (CNI overlays don't deliver L2 ### Why a custom SCC?
broadcast into pod netns) and `NET_BIND_SERVICE` (to bind ports <1024) — nothing else.
No raw sockets, no privileged mode. The Route covers `80/TCP`; PXE clients reach UDP
67/69/4011 on the node's host IP directly.
## Health & observability The default `restricted-v2` blocks `hostNetwork` and all capabilities. PXE
cannot work without host network (CNI overlays don't deliver L2 broadcast
into pod netns), and we need `NET_BIND_SERVICE` to bind <1024. The custom
`openpxe-scc` grants exactly those two and nothing else. No raw sockets,
no privileged mode — proxy-mode DHCP sidesteps the usual requirements.
| Endpoint | Purpose | ### What's on host ports
|----------|---------|
| `/healthz` | Liveness — always 200 if the HTTP stack is up. | | Port | Proto | Purpose |
| `/readyz` | Readiness — 200 only once iPXE firmware is bundled and the ISO dir is reachable. | |----------|-------|---------------------------------|
| `/api/status` | Full JSON: version, assets, counts, live settings, share + SMB state. | | 67 | UDP | DHCP server (proxy replies) |
| `/metrics` | Prometheus text format — DHCP replies by arch, TFTP/HTTP counts, queue gauges, uptime. | | 69 | UDP | TFTP |
| 4011 | UDP | PXE Boot Server discovery |
| 80 | TCP | Web UI + HTTP boot assets |
The OpenShift Route only covers 80/TCP. Clients on the PXE network talk to
the node's host IP directly for UDP.
## Windows support
Enabled by toggling **Windows ISO support** under Settings. The flow:
1. Upload a stock Microsoft Windows install ISO (vanilla, no pre-processing).
2. On upload, OpenPXE extracts the ISO and uses `wimlib-imagex` to rewrite
image index 2 (WinPE) of `sources/boot.wim`. It injects exactly two
plain-text files:
- `Windows/System32/winpeshl.ini` — tells WinPE to run `startnet.cmd`.
- `Windows/System32/startnet.cmd` — runs `wpeinit`, waits for the SMB
host to be reachable, `net use Z: \\<server>\<share> /user:guest`,
then `Z:\setup.exe`.
3. The container's Samba `smbd` serves the extracted install tree on :445.
4. The client gets chainloaded into wimboot → patched WinPE → Windows Setup
running off the SMB share. **Every binary the client executes is stock
Microsoft-signed.**
### What we never do
- Ship drivers — signed, test-signed, or otherwise — that load on the client.
- Install certificates into the target's trust store or WinPE boot policy.
- Recommend `bcdedit /set testsigning on` or any equivalent signing-policy
weakening.
### Credit & limitations
The SMB-based approach is adapted from [Bootimus](https://github.com/garybowers/bootimus)
(Apache-2.0). Re-implemented in Rust; no code was copied verbatim. Known
operational constraints inherited from the design:
- **Port 445 must be directly reachable from PXE clients.** `net use`
ignores alternate ports. In OpenShift this means `hostPort: 445` on the
deployment; on a host that already runs SMB it will collide.
- Windows 10/11 client SKUs are the tested target. Server SKUs untested.
- Hardware with NICs/storage controllers missing from WinPE's bundled
drivers will need a driver-pack injection step (not yet implemented).
## Queued Deployment
The "horse race" launch flow, end to end:
1. A client boots and picks **Queued Deployment** in the PXE menu (or falls
through on timeout with the default `timeout_action`).
2. The client joins the queue, gets a numbered queue position, and enters a
long-poll loop (25s per request, auto-renewed).
3. In the web UI's **Queued Deployment** tab, the operator sees each waiting
client with its MAC, IP, arch, and position.
4. The operator selects an image and clicks **Launch for all waiting**.
The server broadcasts the assignment to every queued client via a
`tokio::sync::Notify`; each client's next poll returns the boot script
for the chosen image.
5. Every client chains the same image at effectively the same moment — the
queue releases and the horses run together.
No user-facing iPXE anywhere in this flow. The client only ever runs
scripts we generate; the operator only interacts with the web UI.
## Architecture ## Architecture
Workspace of focused crates — `core`, `dhcp-proxy`, `tftp`, `http-api`, `iso-store`, See [`docs/architecture.md`](docs/architecture.md) for the protocol stack,
`ipxe-assets`, `webui`, and the `openpxe` binary. See crate layout, and the full decision log.
[`docs/architecture.md`](docs/architecture.md) for the protocol stack, crate layout, and
the full decision log.
## License ## Licence
Dual-licensed under **MIT OR Apache-2.0** — use whichever fits your project. MIT OR Apache-2.0.
-21
View File
@@ -13,7 +13,6 @@ workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
toml.workspace = true toml.workspace = true
figment.workspace = true
thiserror.workspace = true thiserror.workspace = true
anyhow.workspace = true anyhow.workspace = true
tracing.workspace = true tracing.workspace = true
@@ -22,26 +21,6 @@ time.workspace = true
uuid.workspace = true uuid.workspace = true
parking_lot.workspace = true parking_lot.workspace = true
tokio = { workspace = true, features = ["sync", "rt", "macros", "time"] } tokio = { workspace = true, features = ["sync", "rt", "macros", "time"] }
# bcrypt for the admin Forms auth (v0.4.5). Already in the workspace
# for per-ISO boot passwords; just re-exported here.
bcrypt.workspace = true
# v0.5.1: pure-Rust SAML 2.0 SP. bergshamra = XML-DSig verify + exclusive
# c14n (no OpenSSL/C). roxmltree/quick-xml parse + build SAML XML;
# x509-parser pulls the IdP signing cert out of metadata; flate2+base64
# encode the HTTP-Redirect binding's SAMLRequest.
bergshamra.workspace = true
roxmltree.workspace = true
quick-xml.workspace = true
x509-parser.workspace = true
flate2.workspace = true
base64.workspace = true
[dev-dependencies] [dev-dependencies]
tempfile = "3.12" tempfile = "3.12"
# v0.5.4: figment's `Jail` (hermetic env/file sandbox) for the config
# loader tests lives behind the `test` feature.
figment = { workspace = true, features = ["test"] }
# v0.5.1: generate a throwaway self-signed signing cert/key so SAML
# verification tests can produce genuinely signed SAMLResponses.
rcgen = "0.13"
+11 -193
View File
@@ -23,36 +23,6 @@ pub enum ClientArch {
Unknown(u16), Unknown(u16),
} }
/// Which boot binary family to advertise to a client (v0.6.1, extended
/// v0.7.0).
///
/// OpenPXE serves [`DriverMode::Firmware`] first (the firmware's own NIC
/// stack, via `snponly`/`undionly`) and escalates a specific MAC
/// automatically when a boot never completes its handoff:
/// `Firmware → Builtin → Shim`. There is no operator toggle — the DHCP
/// proxy decides per client.
///
/// The `Shim` rung (v0.7.0) covers Secure Boot: firmware with SB enabled
/// downloads our unsigned iPXE fine but refuses to *execute* it, which
/// looks exactly like a failed chainload. After both iPXE builds go
/// unconfirmed, the client is offered the Microsoft-signed shim, which
/// loads the signed GRUB, which fetches a server-rendered menu — a fully
/// signed chain that boots signed distro kernels with SB still on.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DriverMode {
/// Reuse the firmware UNDI/SNP NIC stack (`snponly.efi`, `undionly.kpxe`).
/// Default, smallest, most reliable for chainloading.
#[default]
Firmware,
/// iPXE's own bundled NIC drivers (`ipxe.efi`, `ipxe.pxe`). Fallback for
/// hardware whose firmware NIC stack is missing or buggy.
Builtin,
/// Microsoft-signed shim + GRUB chain (`shimx64.efi`). Final fallback
/// for Secure-Boot-enabled UEFI clients that refuse unsigned iPXE.
Shim,
}
impl ClientArch { impl ClientArch {
#[must_use] #[must_use]
pub fn from_option_93(value: u16) -> Self { pub fn from_option_93(value: u16) -> Self {
@@ -69,73 +39,21 @@ impl ClientArch {
/// Default iPXE binary filename to return via TFTP for this architecture. /// Default iPXE binary filename to return via TFTP for this architecture.
/// Uses `snponly` variants which reuse the firmware's UNDI/SNP network /// Uses `snponly` variants which reuse the firmware's UNDI/SNP network
/// stack — smaller binaries and broader hardware compatibility than the /// stack — smaller binaries and broader hardware compatibility than the
/// all-drivers-included `ipxe.efi`. Equivalent to /// all-drivers-included `ipxe.efi`.
/// [`Self::ipxe_bootfile_mode`] with [`DriverMode::Firmware`]; kept as a
/// convenience for the common firmware-net path.
#[must_use] #[must_use]
pub fn ipxe_bootfile(self) -> Option<&'static str> { pub fn ipxe_bootfile(self) -> Option<&'static str> {
self.ipxe_bootfile_mode(DriverMode::Firmware) Some(match self {
} Self::LegacyX86 => "undionly.kpxe",
Self::Ia32Uefi => "snponly-i386.efi",
/// iPXE binary filename for this architecture under a given network Self::X64Uefi => "snponly.efi",
/// [`DriverMode`]. // ARM32 UEFI: upstream boot.ipxe.org does not publish a prebuilt
/// // snponly variant for this arch. We return None so the DHCP
/// * [`DriverMode::Firmware`] — the `snponly`/`undionly` builds that reuse // proxy declines rather than advertising a file we can't serve.
/// the firmware's UNDI/SNP NIC stack. Smallest, and the most reliable Self::Arm32Uefi | Self::Unknown(_) => return None,
/// choice for chainloading because the firmware just proved its network Self::Arm64Uefi => "snponly-arm64.efi",
/// works by downloading the NBP. This is the default first attempt.
/// * [`DriverMode::Builtin`] — the all-drivers `ipxe.efi`/`ipxe.pxe`
/// builds that carry iPXE's *own* NIC drivers. The automatic fallback
/// for clients whose firmware NIC stack is missing or buggy (v0.6.1):
/// the DHCP proxy escalates a MAC to this mode when a firmware-net boot
/// never completes the iPXE handoff. iPXE still includes the `snp`
/// driver here too, so it degrades gracefully.
#[must_use]
pub fn ipxe_bootfile_mode(self, mode: DriverMode) -> Option<&'static str> {
Some(match (self, mode) {
// Legacy x86 BIOS: UNDI (firmware) vs full native-driver build.
(Self::LegacyX86, DriverMode::Firmware) => "undionly.kpxe",
(Self::LegacyX86, DriverMode::Builtin) => "ipxe.pxe",
// IA32 UEFI.
(Self::Ia32Uefi, DriverMode::Firmware) => "snponly-i386.efi",
(Self::Ia32Uefi, DriverMode::Builtin) => "ipxe-i386.efi",
// No signed Shim chain for BIOS (no Secure Boot there) or
// IA32 UEFI (Fedora publishes no 32-bit shim; SB-on IA32
// clients are vanishingly rare). Same outcome as the
// no-binary arches below, listed separately for the comment.
#[allow(clippy::match_same_arms)]
(Self::LegacyX86 | Self::Ia32Uefi, DriverMode::Shim) => return None,
// x86_64 UEFI — the overwhelmingly common modern client.
(Self::X64Uefi, DriverMode::Firmware) => "snponly.efi",
(Self::X64Uefi, DriverMode::Builtin) => "ipxe.efi",
(Self::X64Uefi, DriverMode::Shim) => "shimx64.efi",
// ARM64 UEFI.
(Self::Arm64Uefi, DriverMode::Firmware) => "snponly-arm64.efi",
(Self::Arm64Uefi, DriverMode::Builtin) => "ipxe-arm64.efi",
(Self::Arm64Uefi, DriverMode::Shim) => "shimaa64.efi",
// ARM32 UEFI: upstream boot.ipxe.org publishes no prebuilt binary
// for this arch in any mode. Unknown arches likewise. Return
// None so the DHCP proxy declines rather than advertising a file
// we can't serve.
(Self::Arm32Uefi | Self::Unknown(_), _) => return None,
}) })
} }
/// Like [`Self::ipxe_bootfile_mode`], but walks back down the
/// escalation ladder (`Shim → Builtin → Firmware`) when the requested
/// mode has no binary for this arch — e.g. a BIOS client whose
/// escalation state reached `Shim` (BIOS has no Secure Boot) falls
/// back to the all-drivers build instead of being ignored.
#[must_use]
pub fn bootfile_with_fallback(self, mode: DriverMode) -> Option<&'static str> {
let ladder: &[DriverMode] = match mode {
DriverMode::Shim => &[DriverMode::Shim, DriverMode::Builtin, DriverMode::Firmware],
DriverMode::Builtin => &[DriverMode::Builtin, DriverMode::Firmware],
DriverMode::Firmware => &[DriverMode::Firmware],
};
ladder.iter().find_map(|m| self.ipxe_bootfile_mode(*m))
}
#[must_use] #[must_use]
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
@@ -208,110 +126,10 @@ mod tests {
fn bootfile_names_stable() { fn bootfile_names_stable() {
assert_eq!(ClientArch::LegacyX86.ipxe_bootfile(), Some("undionly.kpxe")); assert_eq!(ClientArch::LegacyX86.ipxe_bootfile(), Some("undionly.kpxe"));
assert_eq!(ClientArch::X64Uefi.ipxe_bootfile(), Some("snponly.efi")); assert_eq!(ClientArch::X64Uefi.ipxe_bootfile(), Some("snponly.efi"));
assert_eq!( assert_eq!(ClientArch::Arm64Uefi.ipxe_bootfile(), Some("snponly-arm64.efi"));
ClientArch::Arm64Uefi.ipxe_bootfile(),
Some("snponly-arm64.efi")
);
assert_eq!(ClientArch::Unknown(0xFFFF).ipxe_bootfile(), None); assert_eq!(ClientArch::Unknown(0xFFFF).ipxe_bootfile(), None);
} }
#[test]
fn bootfile_default_is_firmware_mode() {
// The convenience method must equal the explicit Firmware mode.
for a in [
ClientArch::LegacyX86,
ClientArch::Ia32Uefi,
ClientArch::X64Uefi,
ClientArch::Arm64Uefi,
ClientArch::Arm32Uefi,
ClientArch::Unknown(0x99),
] {
assert_eq!(
a.ipxe_bootfile(),
a.ipxe_bootfile_mode(DriverMode::Firmware)
);
}
}
#[test]
fn builtin_mode_maps_to_all_drivers_binaries() {
assert_eq!(
ClientArch::LegacyX86.ipxe_bootfile_mode(DriverMode::Builtin),
Some("ipxe.pxe")
);
assert_eq!(
ClientArch::X64Uefi.ipxe_bootfile_mode(DriverMode::Builtin),
Some("ipxe.efi")
);
assert_eq!(
ClientArch::Ia32Uefi.ipxe_bootfile_mode(DriverMode::Builtin),
Some("ipxe-i386.efi")
);
assert_eq!(
ClientArch::Arm64Uefi.ipxe_bootfile_mode(DriverMode::Builtin),
Some("ipxe-arm64.efi")
);
// No binary for ARM32 / unknown in either mode.
assert_eq!(
ClientArch::Arm32Uefi.ipxe_bootfile_mode(DriverMode::Builtin),
None
);
assert_eq!(
ClientArch::Unknown(0x99).ipxe_bootfile_mode(DriverMode::Builtin),
None
);
}
#[test]
fn driver_mode_default_is_firmware() {
assert_eq!(DriverMode::default(), DriverMode::Firmware);
}
#[test]
fn shim_mode_maps_to_signed_chain_on_uefi_only() {
assert_eq!(
ClientArch::X64Uefi.ipxe_bootfile_mode(DriverMode::Shim),
Some("shimx64.efi")
);
assert_eq!(
ClientArch::Arm64Uefi.ipxe_bootfile_mode(DriverMode::Shim),
Some("shimaa64.efi")
);
// No Secure Boot on BIOS, no published 32-bit shim.
assert_eq!(
ClientArch::LegacyX86.ipxe_bootfile_mode(DriverMode::Shim),
None
);
assert_eq!(
ClientArch::Ia32Uefi.ipxe_bootfile_mode(DriverMode::Shim),
None
);
}
#[test]
fn fallback_walks_down_the_ladder() {
// BIOS escalated to Shim → falls back to the all-drivers build.
assert_eq!(
ClientArch::LegacyX86.bootfile_with_fallback(DriverMode::Shim),
Some("ipxe.pxe")
);
// UEFI x64 at Shim gets the real shim.
assert_eq!(
ClientArch::X64Uefi.bootfile_with_fallback(DriverMode::Shim),
Some("shimx64.efi")
);
// Plain modes are unchanged.
assert_eq!(
ClientArch::X64Uefi.bootfile_with_fallback(DriverMode::Firmware),
Some("snponly.efi")
);
// Arches with nothing stay None.
assert_eq!(
ClientArch::Arm32Uefi.bootfile_with_fallback(DriverMode::Shim),
None
);
}
#[test] #[test]
fn firmware_class_detects_ipxe_over_pxeclient() { fn firmware_class_detects_ipxe_over_pxeclient() {
let c = FirmwareClass::classify(Some(b"PXEClient:Arch:00007"), Some(b"iPXE")); let c = FirmwareClass::classify(Some(b"PXEClient:Arch:00007"), Some(b"iPXE"));
-351
View File
@@ -1,351 +0,0 @@
//! Operator authentication — Sonarr/Radarr-style single-admin Forms model.
//!
//! On a fresh install, no admin account exists; the WebUI's first-run
//! flow prompts the operator to create one. After that the chosen
//! credentials gate `/api/*` access. The admin can rotate username +
//! password from Settings → Account.
//!
//! Multi-user RBAC isn't a goal for OpenPXE — the user explicitly asked
//! for "you have access or you don't". When SSO is configured, additional
//! users come in through the IdP; the locally-stored admin is the
//! fallback owner who can change SSO config or the seal-breaker for an
//! IdP outage. So one record is enough.
//!
//! Storage policy mirrors [`crate::host_bindings::HostBindings`] and
//! [`crate::boot_log::BootLog`]: in-memory authoritative; disk is the
//! crash-survival cache; a corrupt `auth.json` falls back to "no admin
//! configured" rather than blocking startup, which puts the UI back
//! into setup mode rather than locking the operator out.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use time::OffsetDateTime;
use crate::{Error, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdminAccount {
pub username: String,
/// bcrypt hash (cost 10). The plaintext password never leaves the
/// request that set it — same discipline as the per-ISO boot password.
pub password_hash: String,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
}
/// Public projection — no hash, safe to ship to the WebUI.
#[derive(Debug, Clone, Serialize)]
pub struct AdminPublic {
pub username: String,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
}
impl From<&AdminAccount> for AdminPublic {
fn from(a: &AdminAccount) -> Self {
Self {
username: a.username.clone(),
created_at: a.created_at,
updated_at: a.updated_at,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Inner {
admin: Option<AdminAccount>,
}
/// In-memory + on-disk admin registry. Cheap to clone.
#[derive(Debug, Clone)]
pub struct AdminStore {
path: Arc<PathBuf>,
inner: Arc<RwLock<Inner>>,
}
impl AdminStore {
/// Load from `<work_dir>/auth.json`, or start empty. A bad file
/// logs a warning and falls back to "no admin configured" — better
/// to surface the setup flow than lock the operator out of their
/// own install.
#[must_use]
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("auth.json");
let inner = match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str::<Inner>(&text) {
Ok(parsed) => parsed,
Err(e) => {
tracing::warn!(
target: "openpxe::auth",
"auth.json present but unreadable ({e}); starting in setup mode"
);
Inner::default()
}
},
Err(_) => Inner::default(),
};
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(inner)),
}
}
/// Has an admin been bootstrapped? Drives the first-run / login
/// fork in the HTTP layer.
#[must_use]
pub fn is_configured(&self) -> bool {
self.inner.read().admin.is_some()
}
/// Public-safe snapshot for the WebUI.
#[must_use]
pub fn snapshot(&self) -> Option<AdminPublic> {
self.inner.read().admin.as_ref().map(AdminPublic::from)
}
/// First-run setup: create the admin account. Fails if one already
/// exists — the HTTP layer surfaces that as 409.
pub fn bootstrap(&self, username: &str, password: &str) -> Result<AdminPublic> {
validate_username(username)?;
validate_password(password)?;
let hash = bcrypt_hash(password)?;
let now = OffsetDateTime::now_utc();
let admin = AdminAccount {
username: username.trim().to_string(),
password_hash: hash,
created_at: now,
updated_at: now,
};
{
let mut g = self.inner.write();
if g.admin.is_some() {
return Err(Error::Invalid("admin account already configured".into()));
}
g.admin = Some(admin.clone());
}
self.persist();
tracing::info!(
target: "openpxe::auth",
username = %admin.username,
"admin account created (first-run setup)"
);
Ok((&admin).into())
}
/// Verify credentials. Returns the admin record (public projection)
/// on success, `Ok(None)` on mismatch, `Err` on systemic bcrypt
/// failure (treated as "auth not available right now" by callers).
pub fn verify(&self, username: &str, password: &str) -> Result<Option<AdminPublic>> {
let Some(admin) = self.inner.read().admin.clone() else {
return Ok(None);
};
if username.trim() != admin.username {
return Ok(None);
}
// bcrypt compares in constant time relative to the same hash.
// Doing the username check first is fine — a username mismatch
// returns immediately, but the only thing leaked is "this isn't
// the admin's username" which the operator already knows.
match bcrypt::verify(password, &admin.password_hash) {
Ok(true) => Ok(Some((&admin).into())),
Ok(false) => Ok(None),
Err(e) => Err(Error::Other(e.into())),
}
}
/// Rotate username and/or password. `current_password` must match
/// the *existing* hash — same flow as Sonarr's "current password
/// required to change". `new_username`/`new_password` are optional:
/// pass only what you want to change.
pub fn update_credentials(
&self,
current_password: &str,
new_username: Option<&str>,
new_password: Option<&str>,
) -> Result<AdminPublic> {
// Re-check ownership before any state mutation.
let existing = self
.inner
.read()
.admin
.clone()
.ok_or_else(|| Error::Invalid("no admin configured".into()))?;
match bcrypt::verify(current_password, &existing.password_hash) {
Ok(true) => {}
Ok(false) => return Err(Error::Invalid("current password is incorrect".into())),
Err(e) => return Err(Error::Other(e.into())),
}
let mut updated = existing.clone();
if let Some(u) = new_username {
validate_username(u)?;
updated.username = u.trim().to_string();
}
if let Some(p) = new_password {
validate_password(p)?;
updated.password_hash = bcrypt_hash(p)?;
}
updated.updated_at = OffsetDateTime::now_utc();
{
let mut g = self.inner.write();
g.admin = Some(updated.clone());
}
self.persist();
tracing::info!(
target: "openpxe::auth",
username = %updated.username,
"admin credentials updated"
);
Ok((&updated).into())
}
fn persist(&self) {
let snap = self.inner.read().clone();
let body = match serde_json::to_vec_pretty(&snap) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::auth", "serialize auth.json: {e}");
return;
}
};
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = self.path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::auth", "write auth.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::warn!(target: "openpxe::auth", "rename auth.json: {e}");
}
}
}
fn validate_username(u: &str) -> Result<()> {
let u = u.trim();
if u.is_empty() {
return Err(Error::Invalid("username must not be empty".into()));
}
if u.len() > 64 {
return Err(Error::Invalid("username must be 64 chars or fewer".into()));
}
if !u.chars().all(|c| c.is_ascii_graphic() && c != ':') {
return Err(Error::Invalid(
"username must be ASCII printable with no ':' character".into(),
));
}
Ok(())
}
fn validate_password(p: &str) -> Result<()> {
if p.len() < 8 {
return Err(Error::Invalid(
"password must be at least 8 characters".into(),
));
}
if p.len() > 256 {
return Err(Error::Invalid(
"password must be 256 characters or fewer".into(),
));
}
Ok(())
}
fn bcrypt_hash(password: &str) -> Result<String> {
bcrypt::hash(password, bcrypt::DEFAULT_COST).map_err(|e| Error::Other(e.into()))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn empty_after_load_when_no_file() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
assert!(!s.is_configured());
assert!(s.snapshot().is_none());
}
#[test]
fn bootstrap_then_verify_round_trip() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
let pub_ = s.bootstrap("admin", "hunter2hunter2").unwrap();
assert_eq!(pub_.username, "admin");
assert!(s.is_configured());
// Correct creds match; wrong creds don't.
assert!(s.verify("admin", "hunter2hunter2").unwrap().is_some());
assert!(s.verify("admin", "wrong").unwrap().is_none());
assert!(s.verify("nobody", "hunter2hunter2").unwrap().is_none());
}
#[test]
fn bootstrap_rejects_second_call() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
s.bootstrap("admin", "hunter2hunter2").unwrap();
let r = s.bootstrap("other", "anotherpass1");
assert!(matches!(r, Err(Error::Invalid(_))));
}
#[test]
fn round_trip_survives_disk_reload() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
s.bootstrap("admin", "hunter2hunter2").unwrap();
drop(s);
let s2 = AdminStore::load_or_default(dir.path());
assert!(s2.is_configured());
assert!(s2.verify("admin", "hunter2hunter2").unwrap().is_some());
}
#[test]
fn update_credentials_requires_current_password() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
s.bootstrap("admin", "hunter2hunter2").unwrap();
// Wrong current password → no change.
let r = s.update_credentials("nope", None, Some("newpassword1"));
assert!(matches!(r, Err(Error::Invalid(_))));
assert!(s.verify("admin", "hunter2hunter2").unwrap().is_some());
// Correct current password rotates only what's supplied.
s.update_credentials("hunter2hunter2", Some("alice"), Some("newpassword1"))
.unwrap();
assert!(s.verify("admin", "hunter2hunter2").unwrap().is_none());
assert!(s.verify("alice", "newpassword1").unwrap().is_some());
}
#[test]
fn update_credentials_partial_password_only_keeps_username() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
s.bootstrap("admin", "hunter2hunter2").unwrap();
s.update_credentials("hunter2hunter2", None, Some("newpassword1"))
.unwrap();
assert!(s.verify("admin", "newpassword1").unwrap().is_some());
}
#[test]
fn validates_username_and_password() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
assert!(s.bootstrap("", "hunter2hunter2").is_err());
assert!(s.bootstrap("ad:min", "hunter2hunter2").is_err()); // ':' reserved
assert!(s.bootstrap("admin", "short").is_err()); // <8 chars
// 65-char username is too long.
let long = "a".repeat(65);
assert!(s.bootstrap(&long, "hunter2hunter2").is_err());
}
}
-249
View File
@@ -1,249 +0,0 @@
//! Boot-event log — "who installed what, when, from where".
//!
//! Each `/boot/<entry>.ipxe` fetch that actually goes on to serve a boot
//! script lands an entry here. The log is bounded in memory (newest-first,
//! ring-buffered at [`BootLog::CAP`]) and is mirrored append-only to
//! `<work_dir>/boot_log.jsonl`. Mirrors `HostBindings`'s "in-memory is
//! authoritative, disk is a cache" policy — a corrupt log file should
//! never block PXE for the network.
//!
//! We deliberately don't push these onto the `LogBus` (the operator
//! terminal stream). The terminal already shows the http traces; the
//! Host log is a curated, persistent, easy-to-scan view of "what got
//! imaged on what hardware" and conflating the two would be noisy.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::io::Write;
use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::Arc;
use time::OffsetDateTime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootEvent {
#[serde(with = "time::serde::rfc3339")]
pub timestamp: OffsetDateTime,
/// Lowercase, colon-separated. `None` when iPXE didn't supply
/// `?mac=${mac}` in the chain URL (older bookmarks, custom scripts).
pub mac: Option<String>,
/// Connecting peer's IP — taken from the TCP socket when available
/// (PXE clients connect direct, no reverse proxy), and falls back to
/// `X-Forwarded-For` for the rare case where one is present.
pub ip: Option<IpAddr>,
/// `BootEntry::id` — the same id used in `/boot/<id>.ipxe`.
pub target_id: String,
/// Human-friendly label: the ISO's filename / volume label / entry
/// title. Pre-resolved at log time so the UI can render without
/// joining against the ISO store (and so "what image was installed?"
/// survives the operator deleting the ISO later).
pub target_title: String,
}
/// In-memory ring + disk-backed append log of boot events. Cheap to
/// clone; the inner state is `Arc<RwLock<_>>`.
#[derive(Debug, Clone)]
pub struct BootLog {
path: Arc<PathBuf>,
inner: Arc<RwLock<VecDeque<BootEvent>>>,
}
impl BootLog {
/// Newest entries we retain in memory. Past this, the oldest gets
/// evicted — the on-disk JSONL keeps the full history for offline
/// inspection. 500 covers a typical install-day's worth without
/// turning the Hosts tab into a wall of text.
pub const CAP: usize = 500;
/// Load up to `CAP` newest events from `<work_dir>/boot_log.jsonl`,
/// or start empty if the file is missing / unreadable.
#[must_use]
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("boot_log.jsonl");
let mut events = VecDeque::with_capacity(Self::CAP);
if let Ok(text) = std::fs::read_to_string(&path) {
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<BootEvent>(line) {
Ok(ev) => {
if events.len() == Self::CAP {
events.pop_front();
}
events.push_back(ev);
}
Err(e) => {
tracing::warn!(
target: "openpxe::boot_log",
"skipping unparseable boot_log line: {e}"
);
}
}
}
}
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(events)),
}
}
/// Append an event. Persistence is best-effort and never blocks the
/// caller on a failed write (the in-memory copy is the source of
/// truth for the live UI; the JSONL is just for crash survival).
pub fn record(&self, ev: &BootEvent) {
// Push into the ring first so a slow / failing disk doesn't lose
// events for the live UI.
{
let mut g = self.inner.write();
if g.len() == Self::CAP {
g.pop_front();
}
g.push_back(ev.clone());
}
tracing::info!(
target: "openpxe::boot_log",
mac = ev.mac.as_deref().unwrap_or("?"),
ip = ev.ip.map(|i| i.to_string()).as_deref().unwrap_or("?"),
target = %ev.target_id,
"boot event"
);
// Append to disk. We tolerate write failures — they'd show up as
// missing entries on the next restart only.
let mut line = match serde_json::to_string(ev) {
Ok(s) => s,
Err(e) => {
tracing::warn!(target: "openpxe::boot_log", "serialize boot event: {e}");
return;
}
};
line.push('\n');
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(self.path.as_path())
{
Ok(mut f) => {
if let Err(e) = f.write_all(line.as_bytes()) {
tracing::warn!(target: "openpxe::boot_log", "append boot_log.jsonl: {e}");
}
}
Err(e) => {
tracing::warn!(target: "openpxe::boot_log", "open boot_log.jsonl: {e}");
}
}
}
/// Newest-first snapshot, up to `CAP` entries.
#[must_use]
pub fn list(&self) -> Vec<BootEvent> {
let g = self.inner.read();
// VecDeque preserves insertion order; reverse so newest is first.
g.iter().rev().cloned().collect()
}
#[must_use]
pub fn len(&self) -> usize {
self.inner.read().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Wipe in-memory + the on-disk file. Used by the `terminal clear`
/// equivalent or future operator action; not currently wired to a UI
/// button but exposed for completeness.
pub fn clear(&self) {
self.inner.write().clear();
let _ = std::fs::remove_file(self.path.as_path());
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn ev(target: &str, mac: Option<&str>) -> BootEvent {
BootEvent {
timestamp: OffsetDateTime::now_utc(),
mac: mac.map(str::to_string),
ip: Some("10.0.0.42".parse().unwrap()),
target_id: target.into(),
target_title: format!("{target}.iso"),
}
}
#[test]
fn record_then_list_is_newest_first() {
let dir = tempdir().unwrap();
let log = BootLog::load_or_default(dir.path());
assert!(log.is_empty());
log.record(&ev("alpha", Some("aa:bb:cc:00:00:01")));
log.record(&ev("beta", Some("aa:bb:cc:00:00:02")));
let list = log.list();
assert_eq!(list.len(), 2);
assert_eq!(list[0].target_id, "beta");
assert_eq!(list[1].target_id, "alpha");
}
#[test]
fn round_trip_through_disk() {
let dir = tempdir().unwrap();
let log = BootLog::load_or_default(dir.path());
log.record(&ev("alpha", Some("aa:bb:cc:00:00:01")));
log.record(&ev("beta", None));
drop(log);
let log2 = BootLog::load_or_default(dir.path());
assert_eq!(log2.len(), 2);
let list = log2.list();
assert_eq!(list[0].target_id, "beta");
assert_eq!(list[1].target_id, "alpha");
assert!(list[0].mac.is_none());
assert_eq!(list[1].mac.as_deref(), Some("aa:bb:cc:00:00:01"));
}
#[test]
fn ring_evicts_oldest_past_cap() {
let dir = tempdir().unwrap();
let log = BootLog::load_or_default(dir.path());
for i in 0..(BootLog::CAP + 5) {
log.record(&ev(&format!("e{i}"), None));
}
assert_eq!(log.len(), BootLog::CAP);
let list = log.list();
// Newest first; the most recent push is the last index inserted.
assert_eq!(list[0].target_id, format!("e{}", BootLog::CAP + 4));
// Oldest in-memory should be the 6th push (0..5 were evicted).
assert_eq!(list[BootLog::CAP - 1].target_id, "e5");
}
#[test]
fn clear_wipes_memory_and_disk() {
let dir = tempdir().unwrap();
let log = BootLog::load_or_default(dir.path());
log.record(&ev("alpha", None));
log.clear();
assert!(log.is_empty());
let log2 = BootLog::load_or_default(dir.path());
assert!(log2.is_empty());
}
#[test]
fn corrupt_disk_lines_are_skipped_not_fatal() {
// Write a file with one valid + one garbage line; loader should
// surface the valid one and skip the garbage.
let dir = tempdir().unwrap();
let path = dir.path().join("boot_log.jsonl");
let valid = serde_json::to_string(&ev("ok", Some("aa:bb:cc:00:00:09"))).unwrap();
std::fs::write(&path, format!("{valid}\nNOT_JSON\n{valid}\n")).unwrap();
let log = BootLog::load_or_default(dir.path());
assert_eq!(log.len(), 2);
}
}
-369
View File
@@ -1,369 +0,0 @@
//! Label-based boot rules + boot-decision webhook (v0.7.0).
//!
//! Generalizes [`crate::host_bindings::HostBindings`] (exact-MAC pins)
//! into ordered, first-match-wins rules over what the boot chain knows
//! about a client — MAC prefix (OUI or longer) and firmware
//! architecture — plus an optional outbound webhook so external
//! automation (CMDB, netbox, a shell script) can decide the boot target
//! per machine, pixiecore-style.
//!
//! Decision order in the boot script handler, most-specific first:
//! 1. exact per-MAC host binding (operator pin)
//! 2. first matching enabled rule here
//! 3. webhook, if configured (fail-open: timeout/error → menu)
//! 4. interactive menu
//!
//! With no rules and no webhook configured the behavior is byte-for-byte
//! what it was before this feature existed — no toggles to flip.
//!
//! Persisted to `<work_dir>/boot_rules.json` with the same "in-memory
//! authoritative, disk is a crash cache, corruption falls back to empty"
//! policy as the host bindings — a bad rules file must never block PXE.
use crate::host_bindings::normalize_mac;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
/// One ordered rule. All present (non-empty) selectors must match —
/// empty selector fields match anything, so a rule with only `arch` set
/// applies to every client of that architecture.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootRule {
/// Case-insensitive MAC prefix, `:`-separated (e.g. `dc:a6:32` for
/// an OUI, or longer). Empty = any MAC.
#[serde(default)]
pub mac_prefix: String,
/// Client architecture selector — matches `ClientArch::as_str()`
/// (`bios`, `uefi-x64`, `uefi-ia32`, `uefi-arm64`). Empty = any.
#[serde(default)]
pub arch: String,
/// Boot entry id (a `BootEntry::id`) or reserved menu name
/// (`_local`, `_queue`, …) to chain to when this rule matches.
/// May be empty for a rule that only pins a driver mode.
#[serde(default)]
pub target: String,
/// v0.7.1: optional first-boot binary pin — `""` (auto: let the
/// escalation ladder decide), `"firmware"`, `"builtin"`, or
/// `"shim"`. Lets an operator declare "this rack is all Secure
/// Boot → serve the signed chain immediately", skipping the
/// learn-by-failing walk entirely for known fleets.
#[serde(default)]
pub driver_mode: String,
/// Rules can be parked without deleting them.
#[serde(default = "default_true")]
pub enabled: bool,
/// Operator note shown in the UI (`"all Pi 4s"`, `"QA rack"`).
#[serde(default)]
pub note: String,
}
fn default_true() -> bool {
true
}
/// The whole persisted config: ordered rules + optional webhook.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct BootRulesConfig {
pub rules: Vec<BootRule>,
/// Optional boot-decision webhook URL. When set, unmatched boots GET
/// `<url>?mac=<mac>&arch=<arch>` and a `200 {"target": "<id>"}`
/// reply chains to that target. Anything else (404, timeout, bad
/// JSON) falls through to the menu. Empty = disabled.
pub webhook_url: String,
}
/// Store for the rules config. Cheap to clone; locks held briefly.
#[derive(Debug, Clone)]
pub struct BootRulesStore {
path: Arc<PathBuf>,
inner: Arc<RwLock<BootRulesConfig>>,
}
impl BootRulesStore {
/// Load from `work_dir/boot_rules.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("boot_rules.json");
let inner = match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str::<BootRulesConfig>(&text) {
Ok(cfg) => cfg,
Err(e) => {
tracing::warn!(
target: "openpxe::boot_rules",
"boot_rules.json present but unreadable ({e}); starting empty"
);
BootRulesConfig::default()
}
},
Err(_) => BootRulesConfig::default(),
};
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(inner)),
}
}
/// Current config snapshot (for the API / UI).
#[must_use]
pub fn snapshot(&self) -> BootRulesConfig {
self.inner.read().clone()
}
/// Replace the whole config (the UI saves the full table at once —
/// rules are ordered, so partial updates would be ambiguous).
pub fn replace(&self, mut cfg: BootRulesConfig) {
for r in &mut cfg.rules {
r.mac_prefix = normalize_mac(&r.mac_prefix);
r.arch = r.arch.trim().to_ascii_lowercase();
r.target = r.target.trim().to_string();
r.driver_mode = r.driver_mode.trim().to_ascii_lowercase();
r.note = r.note.trim().to_string();
}
cfg.webhook_url = cfg.webhook_url.trim().to_string();
*self.inner.write() = cfg;
self.persist();
}
/// Webhook URL, when configured.
#[must_use]
pub fn webhook_url(&self) -> Option<String> {
let g = self.inner.read();
if g.webhook_url.is_empty() {
None
} else {
Some(g.webhook_url.clone())
}
}
/// First enabled rule matching `(mac, arch)`, in stored order.
/// `arch` is the `ClientArch::as_str()` form when the boot chain
/// passed one along, `None` otherwise (older chains).
#[must_use]
pub fn match_target(&self, mac: &str, arch: Option<&str>) -> Option<String> {
self.first_match(mac, arch, |r| {
(!r.target.is_empty()).then(|| r.target.clone())
})
}
/// v0.7.1: first enabled rule that pins a driver mode for `(mac,
/// arch)`. Consulted by the DHCP proxy *before* the automatic
/// escalation ladder — an operator who knows a rack is all Secure
/// Boot pins it to `shim` and those machines never walk the ladder.
/// Unknown mode strings are ignored (forward compatibility).
#[must_use]
pub fn driver_mode_hint(&self, mac: &str, arch: Option<&str>) -> Option<crate::DriverMode> {
self.first_match(mac, arch, |r| match r.driver_mode.as_str() {
"firmware" => Some(crate::DriverMode::Firmware),
"builtin" => Some(crate::DriverMode::Builtin),
"shim" => Some(crate::DriverMode::Shim),
_ => None,
})
}
/// Shared rule-matching walk: returns the first `extract` result from
/// an enabled rule whose selectors match. Rules that match but yield
/// `None` from `extract` (e.g. no target set, or no driver mode set)
/// don't stop the walk — target rules and mode-pin rules coexist.
fn first_match<T>(
&self,
mac: &str,
arch: Option<&str>,
extract: impl Fn(&BootRule) -> Option<T>,
) -> Option<T> {
let mac = normalize_mac(mac);
let g = self.inner.read();
for r in &g.rules {
if !r.enabled {
continue;
}
if !r.mac_prefix.is_empty() && !mac.starts_with(r.mac_prefix.as_str()) {
continue;
}
if !r.arch.is_empty() {
// An arch-selective rule can only match when the chain
// told us the client's arch.
match arch {
Some(a) if a.eq_ignore_ascii_case(&r.arch) => {}
_ => continue,
}
}
if let Some(v) = extract(r) {
return Some(v);
}
}
None
}
fn persist(&self) {
let snap = self.inner.read().clone();
let body = match serde_json::to_vec_pretty(&snap) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::boot_rules", "serialize boot_rules.json: {e}");
return;
}
};
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = self.path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::boot_rules", "write boot_rules.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::warn!(target: "openpxe::boot_rules", "rename boot_rules.json: {e}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn rule(mac_prefix: &str, arch: &str, target: &str) -> BootRule {
BootRule {
mac_prefix: mac_prefix.into(),
arch: arch.into(),
target: target.into(),
driver_mode: String::new(),
enabled: true,
note: String::new(),
}
}
#[test]
fn driver_mode_hint_pins_known_modes_and_ignores_unknown() {
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
let mut sb_rack = rule("aa:bb:cc", "", "");
sb_rack.driver_mode = "SHIM".into(); // normalized on replace
let mut weird = rule("11:22:33", "", "");
weird.driver_mode = "quantum".into(); // unknown → ignored
s.replace(BootRulesConfig {
rules: vec![sb_rack, weird],
webhook_url: String::new(),
});
assert_eq!(
s.driver_mode_hint("aa:bb:cc:00:00:01", None),
Some(crate::DriverMode::Shim)
);
assert_eq!(s.driver_mode_hint("11:22:33:00:00:01", None), None);
assert_eq!(s.driver_mode_hint("99:99:99:00:00:01", None), None);
}
#[test]
fn mode_pin_rule_does_not_shadow_later_target_rule() {
// A mode-only rule and a target rule can both apply to the same
// client: the mode pin must not consume the target walk.
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
let mut pin = rule("aa:bb", "", "");
pin.driver_mode = "builtin".into();
s.replace(BootRulesConfig {
rules: vec![pin, rule("aa:bb", "", "rack-image")],
webhook_url: String::new(),
});
assert_eq!(
s.driver_mode_hint("aa:bb:00:00:00:01", None),
Some(crate::DriverMode::Builtin)
);
assert_eq!(
s.match_target("aa:bb:00:00:00:01", None).as_deref(),
Some("rack-image")
);
}
#[test]
fn empty_config_matches_nothing() {
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
assert!(s
.match_target("aa:bb:cc:dd:ee:ff", Some("uefi-x64"))
.is_none());
assert!(s.webhook_url().is_none());
}
#[test]
fn first_match_wins_in_order() {
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
s.replace(BootRulesConfig {
rules: vec![
rule("aa:bb:cc", "", "rack-image"),
rule("", "", "catch-all"),
],
webhook_url: String::new(),
});
assert_eq!(
s.match_target("AA-BB-CC-00-00-01", None).as_deref(),
Some("rack-image")
);
assert_eq!(
s.match_target("11:22:33:44:55:66", None).as_deref(),
Some("catch-all")
);
}
#[test]
fn arch_selector_requires_known_arch() {
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
s.replace(BootRulesConfig {
rules: vec![rule("", "uefi-arm64", "arm-image")],
webhook_url: String::new(),
});
assert_eq!(
s.match_target("aa:bb:cc:00:00:01", Some("uefi-arm64"))
.as_deref(),
Some("arm-image")
);
// Wrong arch, or arch unknown to the chain → no match.
assert!(s.match_target("aa:bb:cc:00:00:01", Some("bios")).is_none());
assert!(s.match_target("aa:bb:cc:00:00:01", None).is_none());
}
#[test]
fn disabled_rules_are_skipped() {
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
let mut r = rule("", "", "x");
r.enabled = false;
s.replace(BootRulesConfig {
rules: vec![r],
webhook_url: String::new(),
});
assert!(s.match_target("aa:bb:cc:00:00:01", None).is_none());
}
#[test]
fn config_round_trips_to_disk() {
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
s.replace(BootRulesConfig {
rules: vec![rule("DC-A6-32", "", "pi-image")],
webhook_url: " http://automation/boot ".into(),
});
drop(s);
let s2 = BootRulesStore::load_or_default(dir.path());
// Prefix was normalized on replace, webhook trimmed.
assert_eq!(
s2.match_target("dc:a6:32:01:02:03", None).as_deref(),
Some("pi-image")
);
assert_eq!(s2.webhook_url().as_deref(), Some("http://automation/boot"));
}
#[test]
fn corrupt_file_falls_back_to_empty() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("boot_rules.json"), b"{nope").unwrap();
let s = BootRulesStore::load_or_default(dir.path());
assert!(s.snapshot().rules.is_empty());
}
}
-138
View File
@@ -1,138 +0,0 @@
//! One-time(ish) access tokens for unattended answer files (v0.7.0).
//!
//! Why: answer files routinely embed credentials (local admin passwords,
//! domain-join accounts, root hashes). Serving them to anyone who can
//! GET `/unattended/<id>` is exactly the exposure that got WDS
//! hands-free deployment disabled upstream (CVE-2026-0386 hardening
//! guidance). OpenPXE generates every answer-file URL it injects into a
//! boot chain, so it can scope each URL to the boot that requested it:
//! when a boot script is rendered, a short-lived token is minted and
//! appended; the serving endpoint requires it (or a logged-in operator
//! session, so browser testing keeps working).
//!
//! Deliberately multi-use within the TTL rather than strictly one-shot:
//! real installers fetch the same file more than once (initramfs +
//! installer stage, cloud-init retries), and the token's job is to stop
//! *unrelated* hosts from harvesting credentials, not to count fetches.
//!
//! In-memory only. A server restart invalidates outstanding tokens —
//! acceptable because a restart also interrupts the ISO streaming an
//! in-flight install depends on, and the next boot mints fresh ones.
use parking_lot::Mutex;
use std::collections::HashMap;
use std::time::{Duration, Instant};
use uuid::Uuid;
/// Long enough to cover a slow OS install end-to-end (the answer file is
/// fetched early, but cloud-init can re-read late), short enough that a
/// leaked URL goes stale the same afternoon.
const TOKEN_TTL: Duration = Duration::from_hours(4);
/// Hard cap on outstanding tokens; past it the oldest is evicted. Tokens
/// are minted once per boot-script render, so this only matters under
/// abuse, and serving must never become a memory-growth vector.
const MAX_TOKENS: usize = 4096;
#[derive(Debug, Clone)]
struct Grant {
file_id: String,
issued: Instant,
}
/// In-memory token table. Cheap to clone (`Arc`-shared).
#[derive(Debug, Clone, Default)]
pub struct BootTokens {
inner: std::sync::Arc<Mutex<HashMap<String, Grant>>>,
}
impl BootTokens {
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Mint a token granting access to unattended file `file_id` for the
/// next [`TOKEN_TTL`]. Returns the opaque token value to embed in the
/// generated URL.
#[must_use]
pub fn mint(&self, file_id: &str) -> String {
self.mint_at(file_id, Instant::now())
}
/// Is `token` a live grant for `file_id`?
#[must_use]
pub fn check(&self, token: &str, file_id: &str) -> bool {
self.check_at(token, file_id, Instant::now())
}
fn mint_at(&self, file_id: &str, now: Instant) -> String {
let token = Uuid::new_v4().simple().to_string();
let mut g = self.inner.lock();
g.retain(|_, gr| now.duration_since(gr.issued) < TOKEN_TTL);
if g.len() >= MAX_TOKENS {
if let Some(oldest) = g
.iter()
.min_by_key(|(_, gr)| gr.issued)
.map(|(k, _)| k.clone())
{
g.remove(&oldest);
}
}
g.insert(
token.clone(),
Grant {
file_id: file_id.to_string(),
issued: now,
},
);
token
}
fn check_at(&self, token: &str, file_id: &str, now: Instant) -> bool {
let g = self.inner.lock();
g.get(token)
.is_some_and(|gr| gr.file_id == file_id && now.duration_since(gr.issued) < TOKEN_TTL)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mint_then_check_round_trip() {
let t = BootTokens::new();
let tok = t.mint("ks-1");
assert!(t.check(&tok, "ks-1"));
// Multi-use within TTL: a second fetch still passes.
assert!(t.check(&tok, "ks-1"));
// Wrong file id never passes, even with a live token.
assert!(!t.check(&tok, "ks-2"));
// Unknown token never passes.
assert!(!t.check("nope", "ks-1"));
}
#[test]
fn token_expires_after_ttl() {
let t = BootTokens::new();
let now = Instant::now();
let tok = t.mint_at("ks-1", now);
let just_before = TOKEN_TTL.checked_sub(Duration::from_secs(1)).unwrap();
assert!(t.check_at(&tok, "ks-1", now + just_before));
assert!(!t.check_at(&tok, "ks-1", now + TOKEN_TTL + Duration::from_secs(1)));
}
#[test]
fn table_is_capped() {
let t = BootTokens::new();
let now = Instant::now();
let first = t.mint_at("f", now);
for i in 0..MAX_TOKENS {
let _ = t.mint_at(&format!("f{i}"), now + Duration::from_secs(1));
}
// The oldest grant was evicted to stay within the cap.
assert!(!t.check_at(&first, "f", now + Duration::from_secs(2)));
assert!(t.inner.lock().len() <= MAX_TOKENS);
}
}
-665
View File
@@ -1,665 +0,0 @@
//! Operator-controlled branding overrides.
//!
//! v0.5.2 splits the single brand mark into **three independent slots**,
//! FleetDM-style:
//!
//! * `light` — shown in the WebUI top-left and on the form-login page
//! when the active theme is light.
//! * `dark` — same surfaces, when the active theme is dark.
//! * `client` — the raster painted above the iPXE boot menu entries
//! (`/branding/pxe-logo`), i.e. what a PXE client sees on the screen.
//!
//! Each slot lives at `<work_dir>/branding/logo-<slot>.<ext>` and is
//! served in preference to the bundled rainbow-horizon mark when present.
//! Borrowed-from-FleetDM: tenant chrome, same product.
//!
//! Legacy continuity: a pre-v0.5.2 single `logo.<ext>` (recorded under
//! the old `logo_filename`/`logo_mime` keys) is migrated on first load
//! into both the `dark` and `client` slots — that preserves the previous
//! behaviour (one mark fed both the dark WebUI and the PXE screen) until
//! the operator uploads dedicated variants.
//!
//! Storage policy mirrors `HostBindings` / `BootLog`: in-memory cache is
//! authoritative for the current process, disk is the source of truth on
//! restart, and a corrupt cache file falls back to the bundled default
//! rather than blocking startup.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Allowed MIME types for an uploaded logo. We deliberately keep this
/// narrow — anything that can be `<img src="...">`'d into the brand
/// block, no scripts. SVG carries the obvious XSS risk for raw inline
/// HTML; we always serve the bytes as a separate asset with a strict
/// content-type rather than inlining, so SVG is safe.
pub const ALLOWED_LOGO_MIMES: &[&str] = &[
"image/svg+xml",
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
];
/// Disk cap for an uploaded logo. PXE WebUIs are operator-facing — even
/// a generous 2 MB cap is comfortable for any reasonable brand mark and
/// puts a clear bound on memory + serialization cost.
pub const MAX_LOGO_BYTES: usize = 2 * 1024 * 1024;
/// Which branded surface a logo upload targets.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogoSlot {
/// WebUI + form-login page, light theme.
Light,
/// WebUI + form-login page, dark theme.
Dark,
/// iPXE boot-menu background seen by PXE clients.
Client,
}
impl LogoSlot {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
LogoSlot::Light => "light",
LogoSlot::Dark => "dark",
LogoSlot::Client => "client",
}
}
/// Parse a slot name from the URL path segment. Case-insensitive.
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"light" => Some(LogoSlot::Light),
"dark" => Some(LogoSlot::Dark),
"client" => Some(LogoSlot::Client),
_ => None,
}
}
}
/// One brand-mark slot: a filename (relative to the branding dir) plus
/// the MIME we cached at upload time so the HTTP layer can set the
/// Content-Type without re-sniffing.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Slot {
#[serde(default, skip_serializing_if = "Option::is_none")]
filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
mime: Option<String>,
}
impl Slot {
fn clear_file(&mut self, dir: &Path) {
if let Some(name) = self.filename.take() {
let _ = std::fs::remove_file(dir.join(name));
}
self.mime = None;
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Inner {
#[serde(default)]
light: Slot,
#[serde(default)]
dark: Slot,
#[serde(default)]
client: Slot,
/// Monotonic counter bumped on every set/clear (any slot). Surfaces
/// as a cache-bust token (`/assets/logo.svg?r=<rev>`) so the browser
/// fetches the new bytes the moment the operator swaps a logo — the
/// app version alone can't do this since it doesn't change on upload.
/// Persisted so the token stays stable across restarts and keeps
/// climbing across multiple swaps.
#[serde(default)]
rev: u64,
// ── Legacy (pre-v0.5.2) single-logo keys ──────────────────────────
// Read on load for one-way migration into `dark` + `client`, then
// dropped from the persisted form (skip_serializing_if).
#[serde(default, skip_serializing_if = "Option::is_none")]
logo_filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
logo_mime: Option<String>,
}
impl Inner {
fn slot(&self, slot: LogoSlot) -> &Slot {
match slot {
LogoSlot::Light => &self.light,
LogoSlot::Dark => &self.dark,
LogoSlot::Client => &self.client,
}
}
fn slot_mut(&mut self, slot: LogoSlot) -> &mut Slot {
match slot {
LogoSlot::Light => &mut self.light,
LogoSlot::Dark => &mut self.dark,
LogoSlot::Client => &mut self.client,
}
}
}
/// In-memory + on-disk override registry. Cheap to clone; locks are
/// brief. The `branding.json` cache lives alongside the active assets
/// inside `<work_dir>/branding/`.
#[derive(Debug, Clone)]
pub struct BrandingStore {
/// Root directory: `<work_dir>/branding/`. Created on first write.
dir: Arc<PathBuf>,
inner: Arc<RwLock<Inner>>,
}
impl BrandingStore {
/// Load (or initialise empty) from `<work_dir>/branding/`. Tolerates
/// missing directories, partial state, and corrupt JSON — a bad
/// cache should never block PXE for the network. Migrates a legacy
/// single-logo file into the dark + client slots.
#[must_use]
pub fn load_or_default(work_dir: &Path) -> Self {
let dir = work_dir.join("branding");
let path = dir.join("branding.json");
let mut inner = Inner::default();
if let Ok(text) = std::fs::read_to_string(&path) {
match serde_json::from_str::<Inner>(&text) {
Ok(parsed) => inner = parsed,
Err(e) => {
tracing::warn!(
target: "openpxe::branding",
"branding.json present but unreadable ({e}); starting empty"
);
}
}
}
let store = Self {
dir: Arc::new(dir),
inner: Arc::new(RwLock::new(inner)),
};
store.migrate_legacy();
store.prune_missing();
store
}
/// One-way migration: a pre-v0.5.2 `logo.<ext>` becomes the dark +
/// client slots (the old single mark fed both the dark WebUI and the
/// PXE screen). Best-effort; failures leave the legacy file in place
/// rather than blocking startup.
fn migrate_legacy(&self) {
let (legacy_name, legacy_mime) = {
let g = self.inner.read();
(g.logo_filename.clone(), g.logo_mime.clone())
};
let Some(name) = legacy_name else { return };
let src = self.dir.join(&name);
if !src.is_file() {
// Legacy pointer is stale — just drop it.
let mut g = self.inner.write();
g.logo_filename = None;
g.logo_mime = None;
drop(g);
self.persist();
return;
}
let mime = legacy_mime.unwrap_or_else(|| "image/svg+xml".to_string());
let ext = ext_for_mime(&mime).unwrap_or("bin");
if let Ok(bytes) = std::fs::read(&src) {
// Seed dark + client only when those slots are still empty so
// a re-run (or a manual edit) never clobbers operator intent.
let needs_dark = self.inner.read().dark.filename.is_none();
let needs_client = self.inner.read().client.filename.is_none();
if needs_dark {
let _ = self.write_slot(LogoSlot::Dark, &mime, ext, &bytes);
}
if needs_client {
let _ = self.write_slot(LogoSlot::Client, &mime, ext, &bytes);
}
}
let _ = std::fs::remove_file(&src);
{
let mut g = self.inner.write();
g.logo_filename = None;
g.logo_mime = None;
}
self.persist();
tracing::info!(
target: "openpxe::branding",
"migrated legacy single logo into dark + client slots"
);
}
/// Drop in-memory slot pointers whose backing file vanished from disk
/// so the HTTP layer falls back to the bundled mark instead of 500ing.
fn prune_missing(&self) {
let mut changed = false;
{
let mut g = self.inner.write();
for slot in [LogoSlot::Light, LogoSlot::Dark, LogoSlot::Client] {
let present = g
.slot(slot)
.filename
.as_deref()
.is_some_and(|n| self.dir.join(n).is_file());
if !present && g.slot(slot).filename.is_some() {
g.slot_mut(slot).filename = None;
g.slot_mut(slot).mime = None;
changed = true;
}
}
}
if changed {
self.persist();
}
}
/// Absolute path to the logo for `slot`, if set and present on disk.
#[must_use]
pub fn slot_path(&self, slot: LogoSlot) -> Option<PathBuf> {
let g = self.inner.read();
g.slot(slot).filename.as_deref().map(|n| self.dir.join(n))
}
/// MIME of the logo for `slot`, if any.
#[must_use]
pub fn slot_mime(&self, slot: LogoSlot) -> Option<String> {
self.inner.read().slot(slot).mime.clone()
}
/// Resolve the WebUI logo for a theme, with fallback: light falls
/// back to dark and vice-versa, so a single uploaded variant still
/// shows on both themes. Returns `(path, mime)` or `None` (→ bundled).
#[must_use]
pub fn web_logo(&self, theme_is_light: bool) -> Option<(PathBuf, String)> {
let (primary, secondary) = if theme_is_light {
(LogoSlot::Light, LogoSlot::Dark)
} else {
(LogoSlot::Dark, LogoSlot::Light)
};
let g = self.inner.read();
let chosen = if g.slot(primary).filename.is_some() {
primary
} else {
secondary
};
let s = g.slot(chosen);
s.filename.as_deref().map(|n| {
(
self.dir.join(n),
s.mime
.clone()
.unwrap_or_else(|| "image/svg+xml".to_string()),
)
})
}
/// Resolve the PXE client logo (no theme fallback — the PXE screen
/// has a single mark). Returns `(path, mime)` or `None` (→ default
/// composed background).
#[must_use]
pub fn client_logo(&self) -> Option<(PathBuf, String)> {
let g = self.inner.read();
let s = &g.client;
s.filename.as_deref().map(|n| {
(
self.dir.join(n),
s.mime
.clone()
.unwrap_or_else(|| "application/octet-stream".to_string()),
)
})
}
/// Replace the logo for `slot`. Returns the chosen on-disk filename so
/// the caller can echo it back in the API response.
pub fn set_logo(
&self,
slot: LogoSlot,
mime: &str,
ext: &str,
bytes: &[u8],
) -> std::io::Result<String> {
let filename = self.write_slot(slot, mime, ext, bytes)?;
self.persist();
tracing::info!(
target: "openpxe::branding",
slot = slot.as_str(), file = %filename, mime = %mime, size = bytes.len(),
"custom logo installed"
);
Ok(filename)
}
/// Write the bytes for a slot and update the in-memory pointer + rev,
/// without persisting (the caller decides when to flush). Cleans up
/// any sibling `logo-<slot>.*` so there's exactly one file per slot.
fn write_slot(
&self,
slot: LogoSlot,
mime: &str,
ext: &str,
bytes: &[u8],
) -> std::io::Result<String> {
std::fs::create_dir_all(self.dir.as_path())?;
let safe_ext = sanitize_ext(ext);
let stem = format!("logo-{}", slot.as_str());
let filename = format!("{stem}.{safe_ext}");
let final_path = self.dir.join(&filename);
// Atomic write: tmp -> rename.
let tmp = final_path.with_extension(format!("{safe_ext}.tmp"));
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, &final_path)?;
// Clean up any sibling `logo-<slot>.<otherext>`.
if let Ok(entries) = std::fs::read_dir(self.dir.as_path()) {
for e in entries.flatten() {
let p = e.path();
let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
if name.starts_with(&format!("{stem}.")) && name != filename {
let _ = std::fs::remove_file(&p);
}
}
}
let mut g = self.inner.write();
let s = g.slot_mut(slot);
s.filename = Some(filename.clone());
s.mime = Some(mime.to_string());
g.rev = g.rev.wrapping_add(1);
Ok(filename)
}
/// Drop the override for `slot` and return to the bundled / default.
pub fn clear_logo(&self, slot: LogoSlot) -> std::io::Result<()> {
{
let mut g = self.inner.write();
let dir = self.dir.as_path();
g.slot_mut(slot).clear_file(dir);
g.rev = g.rev.wrapping_add(1);
}
self.persist();
tracing::info!(target: "openpxe::branding", slot = slot.as_str(), "custom logo cleared");
Ok(())
}
/// True if a custom logo is configured for `slot`.
#[must_use]
pub fn has_logo(&self, slot: LogoSlot) -> bool {
self.inner.read().slot(slot).filename.is_some()
}
/// True if either WebUI theme slot has a custom logo — drives the
/// FleetDM-style full-width brand block (and the `has-custom-logo`
/// class) on the sidebar + login page.
#[must_use]
pub fn has_any_web_logo(&self) -> bool {
let g = self.inner.read();
g.light.filename.is_some() || g.dark.filename.is_some()
}
/// Presence triple `(light, dark, client)` for the `/api/me` and
/// `/api/status` bootstrap payloads.
#[must_use]
pub fn presence(&self) -> (bool, bool, bool) {
let g = self.inner.read();
(
g.light.filename.is_some(),
g.dark.filename.is_some(),
g.client.filename.is_some(),
)
}
/// Cache-bust token for the logo asset URLs. Changes on every
/// set/clear so `/assets/logo.svg?r=<rev>` resolves to a fresh URL
/// whenever the operator swaps a brand mark. Stable otherwise.
#[must_use]
pub fn logo_rev(&self) -> u64 {
self.inner.read().rev
}
fn persist(&self) {
let snap = self.inner.read().clone();
let body = match serde_json::to_vec_pretty(&snap) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::branding", "serialize branding.json: {e}");
return;
}
};
if let Err(e) = std::fs::create_dir_all(self.dir.as_path()) {
tracing::warn!(target: "openpxe::branding", "mkdir branding/: {e}");
return;
}
let path = self.dir.join("branding.json");
let tmp = path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::branding", "write branding.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, &path) {
tracing::warn!(target: "openpxe::branding", "rename branding.json: {e}");
}
}
}
/// Trim arbitrary operator-supplied extension strings to a small, safe
/// alphanumeric form. Anything weird collapses to `bin`. We never let
/// the extension affect the path beyond the final segment of `logo.<x>`.
fn sanitize_ext(ext: &str) -> String {
let lc: String = ext
.chars()
.filter(char::is_ascii_alphanumeric)
.map(|c| c.to_ascii_lowercase())
.collect();
if lc.is_empty() || lc.len() > 5 {
"bin".into()
} else {
lc
}
}
/// Pick a safe filesystem extension from a MIME type. Returns `None`
/// if the MIME isn't on the [`ALLOWED_LOGO_MIMES`] allowlist.
#[must_use]
pub fn ext_for_mime(mime: &str) -> Option<&'static str> {
match mime {
"image/svg+xml" => Some("svg"),
"image/png" => Some("png"),
"image/jpeg" => Some("jpg"),
"image/webp" => Some("webp"),
"image/gif" => Some("gif"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn empty_after_load_when_no_branding_dir() {
let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path());
assert!(!b.has_logo(LogoSlot::Light));
assert!(!b.has_logo(LogoSlot::Dark));
assert!(!b.has_logo(LogoSlot::Client));
assert!(b.web_logo(false).is_none());
assert!(b.client_logo().is_none());
assert!(!b.has_any_web_logo());
}
#[test]
fn set_clear_round_trip_persists() {
let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path());
let name = b
.set_logo(LogoSlot::Dark, "image/png", "png", b"\x89PNG\r\n\x1a\nfake")
.unwrap();
assert_eq!(name, "logo-dark.png");
assert!(b.has_logo(LogoSlot::Dark));
assert_eq!(b.slot_mime(LogoSlot::Dark).as_deref(), Some("image/png"));
let (p, _) = b.web_logo(false).unwrap();
assert!(p.is_file());
// Re-open and confirm the override survives a restart.
drop(b);
let b2 = BrandingStore::load_or_default(dir.path());
assert!(b2.has_logo(LogoSlot::Dark));
assert_eq!(b2.slot_mime(LogoSlot::Dark).as_deref(), Some("image/png"));
// Clear; the file goes away and has_logo flips off.
b2.clear_logo(LogoSlot::Dark).unwrap();
assert!(!b2.has_logo(LogoSlot::Dark));
assert!(!p.exists());
}
#[test]
fn web_logo_falls_back_across_themes() {
let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path());
// Only dark uploaded — light theme falls back to it.
b.set_logo(LogoSlot::Dark, "image/png", "png", b"dark")
.unwrap();
let (p_light, _) = b.web_logo(true).expect("light falls back to dark");
assert!(p_light.ends_with("logo-dark.png"));
// Upload a distinct light — now light theme uses its own.
b.set_logo(LogoSlot::Light, "image/png", "png", b"light")
.unwrap();
let (p_light2, _) = b.web_logo(true).unwrap();
assert!(p_light2.ends_with("logo-light.png"));
// Client is independent and still unset.
assert!(b.client_logo().is_none());
}
#[test]
fn replacing_slot_removes_old_extension_sibling() {
let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path());
b.set_logo(
LogoSlot::Client,
"image/png",
"png",
b"\x89PNG\r\n\x1a\nfake",
)
.unwrap();
b.set_logo(
LogoSlot::Client,
"image/svg+xml",
"svg",
br#"<svg xmlns="http://www.w3.org/2000/svg"/>"#,
)
.unwrap();
let entries: Vec<_> = std::fs::read_dir(dir.path().join("branding"))
.unwrap()
.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned()))
.collect();
assert!(
entries.iter().any(|n| n == "logo-client.svg"),
"got {entries:?}"
);
assert!(
!entries.iter().any(|n| n == "logo-client.png"),
"stale PNG left over: {entries:?}"
);
}
#[test]
fn logo_rev_bumps_on_each_set_and_clear() {
let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path());
assert_eq!(b.logo_rev(), 0);
b.set_logo(LogoSlot::Light, "image/png", "png", b"a")
.unwrap();
assert_eq!(b.logo_rev(), 1);
b.set_logo(LogoSlot::Dark, "image/png", "png", b"b")
.unwrap();
assert_eq!(b.logo_rev(), 2);
b.clear_logo(LogoSlot::Light).unwrap();
assert_eq!(b.logo_rev(), 3);
drop(b);
let b2 = BrandingStore::load_or_default(dir.path());
assert_eq!(b2.logo_rev(), 3);
}
#[test]
fn legacy_single_logo_migrates_to_dark_and_client() {
// A pre-v0.5.2 branding.json + logo.png migrates on load.
let dir = tempdir().unwrap();
let brand_dir = dir.path().join("branding");
std::fs::create_dir_all(&brand_dir).unwrap();
std::fs::write(brand_dir.join("logo.png"), b"\x89PNG\r\n\x1a\nlegacy").unwrap();
// Hand-write the old shape (logo_filename/logo_mime, no slots).
std::fs::write(
brand_dir.join("branding.json"),
br#"{"logo_filename":"logo.png","logo_mime":"image/png","rev":4}"#,
)
.unwrap();
let b = BrandingStore::load_or_default(dir.path());
assert!(b.has_logo(LogoSlot::Dark), "dark seeded from legacy");
assert!(b.has_logo(LogoSlot::Client), "client seeded from legacy");
assert!(!b.has_logo(LogoSlot::Light), "light stays empty");
// The old logo.png is gone; per-slot files exist.
assert!(!brand_dir.join("logo.png").exists());
assert!(brand_dir.join("logo-dark.png").is_file());
assert!(brand_dir.join("logo-client.png").is_file());
// rev carried over from the legacy file and advanced as the two
// slots were seeded (each write bumps it), so it never regresses.
let migrated_rev = b.logo_rev();
assert!(
migrated_rev >= 4,
"rev should not regress below legacy: {migrated_rev}"
);
// And the migration is sticky across a restart (no re-migrate, no
// further rev churn).
drop(b);
let b2 = BrandingStore::load_or_default(dir.path());
assert!(b2.has_logo(LogoSlot::Dark));
assert!(b2.has_logo(LogoSlot::Client));
assert!(!b2.has_logo(LogoSlot::Light));
assert_eq!(b2.logo_rev(), migrated_rev, "restart must not re-migrate");
}
#[test]
fn sanitize_ext_strips_separators_and_path_chars() {
assert_eq!(sanitize_ext("svg"), "svg");
assert_eq!(sanitize_ext("../etc/passwd"), "bin");
assert_eq!(sanitize_ext("../svg"), "svg");
assert_eq!(sanitize_ext(""), "bin");
assert_eq!(sanitize_ext("PNG"), "png");
assert_eq!(sanitize_ext("svgvvvv"), "bin");
}
#[test]
fn missing_file_referenced_by_json_resolves_to_empty() {
let dir = tempdir().unwrap();
let brand_dir = dir.path().join("branding");
std::fs::create_dir_all(&brand_dir).unwrap();
// branding.json claims a dark slot whose file doesn't exist.
std::fs::write(
brand_dir.join("branding.json"),
br#"{"dark":{"filename":"logo-dark.png","mime":"image/png"},"rev":1}"#,
)
.unwrap();
let b = BrandingStore::load_or_default(dir.path());
assert!(
!b.has_logo(LogoSlot::Dark),
"should fall back when referenced file is missing"
);
}
#[test]
fn slot_parse_round_trips() {
assert_eq!(LogoSlot::parse("light"), Some(LogoSlot::Light));
assert_eq!(LogoSlot::parse("DARK"), Some(LogoSlot::Dark));
assert_eq!(LogoSlot::parse(" client "), Some(LogoSlot::Client));
assert_eq!(LogoSlot::parse("nope"), None);
assert_eq!(LogoSlot::Light.as_str(), "light");
}
#[test]
fn ext_for_mime_only_accepts_known_types() {
assert_eq!(ext_for_mime("image/png"), Some("png"));
assert_eq!(ext_for_mime("image/svg+xml"), Some("svg"));
assert_eq!(ext_for_mime("application/octet-stream"), None);
assert_eq!(ext_for_mime("text/html"), None);
}
}
+28 -17
View File
@@ -12,9 +12,11 @@ use time::OffsetDateTime;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ClientEvent { pub enum ClientEvent {
DhcpDiscover, DhcpDiscover,
DhcpRequest,
PxeBootServerRequest, PxeBootServerRequest,
TftpRead { file: String }, TftpRead { file: String },
HttpScriptFetch { target: String }, HttpScriptFetch { target: String },
HttpIsoAsset { file: String },
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -30,6 +32,8 @@ pub struct ClientSnapshot {
// Events are left with default serialization (9-tuple) — they're // Events are left with default serialization (9-tuple) — they're
// diagnostic only and not consumed by the UI today. // diagnostic only and not consumed by the UI today.
pub events: Vec<(OffsetDateTime, ClientEvent)>, pub events: Vec<(OffsetDateTime, ClientEvent)>,
/// The boot target (ISO id) last selected via the iPXE menu, if any.
pub selected_target: Option<String>,
} }
#[derive(Debug, Default)] #[derive(Debug, Default)]
@@ -52,24 +56,19 @@ impl ClientRegistry {
) { ) {
let mut guard = self.inner.write(); let mut guard = self.inner.write();
let now = OffsetDateTime::now_utc(); let now = OffsetDateTime::now_utc();
let entry = guard let entry = guard.entry(mac.to_string()).or_insert_with(|| ClientSnapshot {
.entry(mac.to_string()) mac: mac.to_string(),
.or_insert_with(|| ClientSnapshot { last_ip: ip,
mac: mac.to_string(), arch,
last_ip: ip, hostname: None,
arch, first_seen: now,
hostname: None, last_seen: now,
first_seen: now, events: Vec::new(),
last_seen: now, selected_target: None,
events: Vec::new(), });
});
entry.last_seen = now; entry.last_seen = now;
if ip.is_some() { if ip.is_some() { entry.last_ip = ip; }
entry.last_ip = ip; if arch.is_some() { entry.arch = arch; }
}
if arch.is_some() {
entry.arch = arch;
}
entry.events.push((now, event)); entry.events.push((now, event));
// Cap event history per client to keep memory bounded. // Cap event history per client to keep memory bounded.
const MAX_EVENTS: usize = 64; const MAX_EVENTS: usize = 64;
@@ -79,6 +78,13 @@ impl ClientRegistry {
} }
} }
pub fn set_selected_target(&self, mac: &str, target: Option<String>) {
let mut guard = self.inner.write();
if let Some(c) = guard.get_mut(mac) {
c.selected_target = target;
}
}
#[must_use] #[must_use]
pub fn list(&self) -> Vec<ClientSnapshot> { pub fn list(&self) -> Vec<ClientSnapshot> {
let guard = self.inner.read(); let guard = self.inner.read();
@@ -87,4 +93,9 @@ impl ClientRegistry {
v.sort_by_key(|c| std::cmp::Reverse(c.last_seen)); v.sort_by_key(|c| std::cmp::Reverse(c.last_seen));
v v
} }
#[must_use]
pub fn get(&self, mac: &str) -> Option<ClientSnapshot> {
self.inner.read().get(mac).cloned()
}
} }
+40 -167
View File
@@ -1,5 +1,3 @@
use figment::providers::{Env, Format, Serialized, Toml};
use figment::Figment;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr}; use std::net::{IpAddr, Ipv4Addr};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -40,6 +38,10 @@ pub struct NetworkConfig {
pub dhcp_port: u16, pub dhcp_port: u16,
/// UDP port for PXE Boot Server discovery. Standard is 4011. /// UDP port for PXE Boot Server discovery. Standard is 4011.
pub pxe_port: u16, pub pxe_port: u16,
/// Optional allowlist of client MAC prefixes (OUI). Empty = serve everyone.
pub mac_allowlist: Vec<String>,
/// Optional allowlist of subnets (CIDR). Empty = serve everyone.
pub subnet_allowlist: Vec<String>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
@@ -54,9 +56,6 @@ pub enum DhcpMode {
/// 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`. OpenPXE 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.
// `off`/`none` are accepted as aliases for backward-compat with the old
// hand-rolled `apply_env`, which mapped them to Disabled.
#[serde(alias = "off", alias = "none")]
Disabled, Disabled,
} }
@@ -69,17 +68,12 @@ pub struct Paths {
pub work_dir: PathBuf, pub work_dir: PathBuf,
/// Directory containing bundled iPXE binaries (undionly.kpxe, snponly.efi, ...). /// Directory containing bundled iPXE binaries (undionly.kpxe, snponly.efi, ...).
pub ipxe_dir: PathBuf, pub ipxe_dir: PathBuf,
/// Path to the wimboot binary for Windows ISOs (optional — feature-controlled). /// Path to the wimboot binary for Windows ISOs (optional — feature-gated).
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/openpxe/smb` in the container image. /// `/var/lib/openpxe/smb` in the container image.
pub smb_dir: PathBuf, pub smb_dir: PathBuf,
/// v0.5.2: directory holding uploaded unattended-install answer files
/// (Kickstart / Preseed / Autoinstall / Windows answer files). Kept
/// separate from `iso_dir` so answer files never appear in the ISO
/// listing or the PXE menu. Defaults to `/var/lib/openpxe/unattended`.
pub unattended_dir: PathBuf,
} }
impl Default for ServerConfig { impl Default for ServerConfig {
@@ -101,6 +95,8 @@ impl Default for NetworkConfig {
dhcp_bind: IpAddr::V4(Ipv4Addr::UNSPECIFIED), dhcp_bind: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
dhcp_port: 67, dhcp_port: 67,
pxe_port: 4011, pxe_port: 4011,
mac_allowlist: Vec::new(),
subnet_allowlist: Vec::new(),
} }
} }
} }
@@ -113,7 +109,6 @@ impl Default for Paths {
ipxe_dir: PathBuf::from("/usr/share/openpxe/ipxe"), ipxe_dir: PathBuf::from("/usr/share/openpxe/ipxe"),
wimboot_path: None, wimboot_path: None,
smb_dir: PathBuf::from("/var/lib/openpxe/smb"), smb_dir: PathBuf::from("/var/lib/openpxe/smb"),
unattended_dir: PathBuf::from("/var/lib/openpxe/unattended"),
} }
} }
} }
@@ -128,162 +123,40 @@ impl Config {
toml::from_str(&text).map_err(|e| crate::Error::Config(e.to_string())) toml::from_str(&text).map_err(|e| crate::Error::Config(e.to_string()))
} }
/// Load configuration with layered precedence (v0.5.4, via `figment`): /// Apply environment variable overrides. Env var names follow the pattern
/// built-in [`Default`] → optional TOML file → `OPENPXE_*` environment /// `OPENPXE_<SECTION>_<FIELD>`, uppercase. Unknown vars are ignored.
/// (highest). Replaces the old `from_toml_file` + `apply_env` two-step /// Call this after loading the TOML file so env takes precedence.
/// and now covers **every** field automatically (the previous hand-rolled pub fn apply_env(&mut self) {
/// mapping silently skipped `unattended_dir`, the bind addresses, etc.). if let Ok(v) = std::env::var("OPENPXE_HTTP_PORT") {
/// if let Ok(p) = v.parse() { self.server.http_port = p; }
/// The env layer preserves the historical flat names
/// (`OPENPXE_HTTP_PORT`, `OPENPXE_ISO_DIR`, …) so existing deployments
/// (the Unraid template, `entrypoint.sh`) keep working unchanged, and
/// additionally accepts the explicit nested form
/// `OPENPXE_<SECTION>__<FIELD>` (double underscore).
pub fn load(path: Option<&Path>) -> crate::Result<Self> {
let mut fig = Figment::from(Serialized::defaults(Config::default()));
if let Some(p) = path {
if p.exists() {
fig = fig.merge(Toml::file(p));
}
} }
fig = fig.merge(env_provider()); if let Ok(v) = std::env::var("OPENPXE_TFTP_PORT") {
fig.extract() if let Ok(p) = v.parse() { self.server.tftp_port = p; }
.map_err(|e| crate::Error::Config(e.to_string())) }
} if let Ok(v) = std::env::var("OPENPXE_DHCP_PORT") {
} if let Ok(p) = v.parse() { self.network.dhcp_port = p; }
}
/// The `OPENPXE_*` environment provider. Maps the historical flat variable if let Ok(v) = std::env::var("OPENPXE_PUBLIC_IP") {
/// names onto the nested [`Config`] fields, and also accepts the explicit if let Ok(ip) = v.parse() { self.server.public_ip = Some(ip); }
/// `OPENPXE_SECTION__FIELD` nested form. Keys that match nothing (e.g. }
/// `OPENPXE_CONFIG`, `OPENPXE_UID` from the entrypoint) become stray if let Ok(v) = std::env::var("OPENPXE_DHCP_MODE") {
/// top-level keys that `Config` ignores on extract. self.network.dhcp_mode = match v.to_ascii_lowercase().as_str() {
fn env_provider() -> Env { "proxy" => DhcpMode::Proxy,
Env::prefixed("OPENPXE_") "disabled" | "off" | "none" => DhcpMode::Disabled,
.map(|key| { _ => self.network.dhcp_mode,
// Lowercase so the match is robust regardless of how the OS
// reports the var's case.
let k = key.as_str().to_ascii_lowercase();
let mapped = match k.as_str() {
"http_port" => "server.http_port",
"http_bind" => "server.http_bind",
"tftp_port" => "server.tftp_port",
"tftp_bind" => "server.tftp_bind",
"public_ip" => "server.public_ip",
"dhcp_port" => "network.dhcp_port",
"dhcp_bind" => "network.dhcp_bind",
"dhcp_mode" => "network.dhcp_mode",
"pxe_port" => "network.pxe_port",
"iso_dir" => "paths.iso_dir",
"work_dir" => "paths.work_dir",
"ipxe_dir" => "paths.ipxe_dir",
"smb_dir" => "paths.smb_dir",
"wimboot_path" => "paths.wimboot_path",
"unattended_dir" => "paths.unattended_dir",
// Unknown: support the explicit nested form
// (OPENPXE_SERVER__HTTP_PORT). `replace` is a no-op for the
// already-handled flat names above.
other => return other.replace("__", ".").into(),
}; };
mapped.into() }
}) if let Ok(v) = std::env::var("OPENPXE_ISO_DIR") {
.split(".") self.paths.iso_dir = PathBuf::from(v);
} }
if let Ok(v) = std::env::var("OPENPXE_WORK_DIR") {
#[cfg(test)] self.paths.work_dir = PathBuf::from(v);
mod tests { }
// figment's `Jail::expect_with` closure returns `Result<(), figment::Error>` if let Ok(v) = std::env::var("OPENPXE_IPXE_DIR") {
// and `figment::Error` is large; that's the library's API, not ours. self.paths.ipxe_dir = PathBuf::from(v);
#![allow(clippy::result_large_err)] }
use super::*; if let Ok(v) = std::env::var("OPENPXE_SMB_DIR") {
self.paths.smb_dir = PathBuf::from(v);
#[test] }
fn defaults_load_when_no_file_or_env() {
figment::Jail::expect_with(|_jail| {
let c = Config::load(None).expect("load defaults");
assert_eq!(c.server.http_port, 80);
assert_eq!(c.network.dhcp_mode, DhcpMode::Proxy);
assert_eq!(c.paths.iso_dir, PathBuf::from("/var/lib/openpxe/isos"));
Ok(())
});
}
#[test]
fn legacy_flat_env_vars_still_apply() {
figment::Jail::expect_with(|jail| {
jail.set_env("OPENPXE_HTTP_PORT", "8123");
jail.set_env("OPENPXE_TFTP_PORT", "6900");
jail.set_env("OPENPXE_DHCP_PORT", "6767");
jail.set_env("OPENPXE_PXE_PORT", "4444");
jail.set_env("OPENPXE_PUBLIC_IP", "10.20.30.40");
jail.set_env("OPENPXE_DHCP_MODE", "disabled");
jail.set_env("OPENPXE_ISO_DIR", "/data/isos");
jail.set_env("OPENPXE_WORK_DIR", "/data/work");
jail.set_env("OPENPXE_IPXE_DIR", "/data/ipxe");
jail.set_env("OPENPXE_SMB_DIR", "/data/smb");
// v0.5.4: a field the old apply_env never covered.
jail.set_env("OPENPXE_UNATTENDED_DIR", "/data/unattended");
let c = Config::load(None).expect("load with env");
assert_eq!(c.server.http_port, 8123);
assert_eq!(c.server.tftp_port, 6900);
assert_eq!(c.network.dhcp_port, 6767);
assert_eq!(c.network.pxe_port, 4444);
assert_eq!(c.server.public_ip, Some("10.20.30.40".parse().unwrap()));
assert_eq!(c.network.dhcp_mode, DhcpMode::Disabled);
assert_eq!(c.paths.iso_dir, PathBuf::from("/data/isos"));
assert_eq!(c.paths.work_dir, PathBuf::from("/data/work"));
assert_eq!(c.paths.ipxe_dir, PathBuf::from("/data/ipxe"));
assert_eq!(c.paths.smb_dir, PathBuf::from("/data/smb"));
assert_eq!(c.paths.unattended_dir, PathBuf::from("/data/unattended"));
Ok(())
});
}
#[test]
fn dhcp_mode_off_alias_maps_to_disabled() {
figment::Jail::expect_with(|jail| {
jail.set_env("OPENPXE_DHCP_MODE", "off");
let c = Config::load(None).unwrap();
assert_eq!(c.network.dhcp_mode, DhcpMode::Disabled);
Ok(())
});
}
#[test]
fn nested_double_underscore_form_also_works() {
figment::Jail::expect_with(|jail| {
jail.set_env("OPENPXE_SERVER__HTTP_PORT", "9001");
let c = Config::load(None).unwrap();
assert_eq!(c.server.http_port, 9001);
Ok(())
});
}
#[test]
fn env_overrides_toml_file() {
figment::Jail::expect_with(|jail| {
jail.create_file(
"openpxe.toml",
"[server]\nhttp_port = 8080\n[paths]\niso_dir = \"/from/toml\"\n",
)?;
jail.set_env("OPENPXE_HTTP_PORT", "8443");
let c = Config::load(Some(Path::new("openpxe.toml"))).unwrap();
// env wins over TOML…
assert_eq!(c.server.http_port, 8443);
// …but TOML-only values still apply.
assert_eq!(c.paths.iso_dir, PathBuf::from("/from/toml"));
Ok(())
});
}
#[test]
fn unrelated_openpxe_env_vars_are_ignored() {
figment::Jail::expect_with(|jail| {
// entrypoint.sh sets these; they must not break config load.
jail.set_env("OPENPXE_UID", "10001");
jail.set_env("OPENPXE_CONFIG", "/etc/openpxe.toml");
let c = Config::load(None).expect("stray vars ignored");
assert_eq!(c.server.http_port, 80);
Ok(())
});
} }
} }
-65
View File
@@ -1,65 +0,0 @@
//! Small, dependency-free encoding helpers shared across crates.
//!
//! v0.5.4: `pct_encode` and `xml_escape` were duplicated in the SAML
//! modules and the HTTP layer; they live here now. They're deliberately
//! hand-rolled rather than pulling in `percent-encoding` / `url`: the
//! unreserved set below is exactly the RFC 3986 set that iPXE's
//! `:uristring` modifier and the SAML HTTP-Redirect binding both expect,
//! and a general-purpose URL crate escapes a different set.
use std::fmt::Write as _;
/// Percent-encode `s` per RFC 3986: the unreserved set
/// (`A-Z` `a-z` `0-9` `-` `_` `.` `~`) passes through unchanged; every
/// other byte becomes `%XX` (uppercase hex).
#[must_use]
pub fn pct_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
let _ = write!(out, "%{b:02X}");
}
}
}
out
}
/// Escape the five XML predefined entities so `s` is safe inside element
/// text or a double-quoted attribute value.
#[must_use]
pub fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pct_encode_unreserved_passthrough_else_hex() {
assert_eq!(pct_encode("node-7.lab_1~"), "node-7.lab_1~");
assert_eq!(pct_encode("aa:bb cc/?&="), "aa%3Abb%20cc%2F%3F%26%3D");
assert_eq!(pct_encode(""), "");
}
#[test]
fn xml_escape_all_five_entities() {
assert_eq!(xml_escape("a&b<c>\"d'e"), "a&amp;b&lt;c&gt;&quot;d&apos;e");
assert_eq!(xml_escape("plain text"), "plain text");
}
}
+13 -72
View File
@@ -1,9 +1,10 @@
//! Per-MAC host bindings. //! Per-MAC host bindings.
//! //!
//! Operators can attach a preferred boot target (a `BootEntry::id`) to a //! Inspired by the Tinkerbell `smee` "MAC-prepended URL" pattern: an
//! specific MAC address. When a client with that MAC arrives, the top-level //! operator can attach a preferred boot target (a `BootEntry::id`) to a
//! boot script chains straight to that target instead of showing the //! specific MAC address. When a client with that MAC arrives, the
//! interactive menu. //! top-level boot script chains straight to that target instead of
//! showing the interactive menu.
//! //!
//! Use cases: //! Use cases:
//! - "This rack of Dell servers always images with Ubuntu Server 24.04" //! - "This rack of Dell servers always images with Ubuntu Server 24.04"
@@ -21,8 +22,6 @@ use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use time::OffsetDateTime; use time::OffsetDateTime;
use crate::profile::DeployProfile;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostBinding { pub struct HostBinding {
/// Lowercase, colon-separated MAC (e.g. `aa:bb:cc:dd:ee:ff`). The /// Lowercase, colon-separated MAC (e.g. `aa:bb:cc:dd:ee:ff`). The
@@ -30,18 +29,13 @@ pub struct HostBinding {
/// don't have to worry about case. /// don't have to worry about case.
pub mac: String, pub mac: String,
/// Preferred boot entry id (matches a `BootEntry::id` in the iso /// Preferred boot entry id (matches a `BootEntry::id` in the iso
/// store) OR one of the reserved menu names: `_local`, `_queue`, /// store) OR one of the reserved menu names: `_local`, `_gate`,
/// `_tools_menu`. Empty string falls back to the menu. /// `_tools_menu`. Empty string falls back to the menu.
pub target: String, pub target: String,
/// Optional human-readable label shown in the UI (`"Tom's laptop"`, /// Optional human-readable label shown in the UI (`"Tom's laptop"`,
/// `"rack-3 spine"`). Empty if unset. /// `"rack-3 spine"`). Empty if unset.
#[serde(default)] #[serde(default)]
pub label: String, pub label: String,
/// v0.5.2: optional unattended-install hints (auto hostname / IP /
/// answer-file id). Flattened into the binding JSON so pre-v0.5.2
/// `hosts.json` files (which lack these keys) still deserialize.
#[serde(default, flatten)]
pub profile: DeployProfile,
#[serde(with = "time::serde::rfc3339")] #[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime, pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")] #[serde(with = "time::serde::rfc3339")]
@@ -101,31 +95,20 @@ impl HostBindings {
} }
/// Insert or update. Returns the resulting binding (with timestamps). /// Insert or update. Returns the resulting binding (with timestamps).
/// The `profile` carries optional unattended-install hints (v0.5.2); pub fn upsert(&self, mac: &str, target: &str, label: &str) -> HostBinding {
/// pass `DeployProfile::default()` for a plain pin.
pub fn upsert(
&self,
mac: &str,
target: &str,
label: &str,
profile: DeployProfile,
) -> HostBinding {
let key = normalize_mac(mac); let key = normalize_mac(mac);
let now = OffsetDateTime::now_utc(); let now = OffsetDateTime::now_utc();
let profile = profile.normalized();
let binding = { let binding = {
let mut g = self.inner.write(); let mut g = self.inner.write();
let entry = g.by_mac.entry(key.clone()).or_insert_with(|| HostBinding { let entry = g.by_mac.entry(key.clone()).or_insert_with(|| HostBinding {
mac: key.clone(), mac: key.clone(),
target: target.to_string(), target: target.to_string(),
label: label.to_string(), label: label.to_string(),
profile: profile.clone(),
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });
entry.target = target.to_string(); entry.target = target.to_string();
entry.label = label.to_string(); entry.label = label.to_string();
entry.profile = profile.clone();
entry.updated_at = now; entry.updated_at = now;
entry.clone() entry.clone()
}; };
@@ -197,10 +180,6 @@ mod tests {
use super::*; use super::*;
use tempfile::tempdir; use tempfile::tempdir;
fn np() -> DeployProfile {
DeployProfile::default()
}
#[test] #[test]
fn normalize_handles_case_and_dashes() { 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");
@@ -213,12 +192,7 @@ mod tests {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path()); let h = HostBindings::load_or_default(dir.path());
assert!(h.is_empty()); assert!(h.is_empty());
h.upsert( h.upsert("AA:BB:CC:00:00:01", "ubuntu-24-04-linux", "rack-3 spine");
"AA:BB:CC:00:00:01",
"ubuntu-24-04-linux",
"rack-3 spine",
np(),
);
let found = h.lookup("aa-bb-cc-00-00-01").expect("lookup"); let found = h.lookup("aa-bb-cc-00-00-01").expect("lookup");
assert_eq!(found.target, "ubuntu-24-04-linux"); assert_eq!(found.target, "ubuntu-24-04-linux");
assert_eq!(found.label, "rack-3 spine"); assert_eq!(found.label, "rack-3 spine");
@@ -229,8 +203,8 @@ mod tests {
fn upsert_replaces_existing_target() { fn upsert_replaces_existing_target() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path()); let h = HostBindings::load_or_default(dir.path());
h.upsert("aa:bb:cc:00:00:01", "old-target", "label1", np()); h.upsert("aa:bb:cc:00:00:01", "old-target", "label1");
h.upsert("aa:bb:cc:00:00:01", "new-target", "label2", np()); h.upsert("aa:bb:cc:00:00:01", "new-target", "label2");
assert_eq!(h.len(), 1); assert_eq!(h.len(), 1);
let b = h.lookup("aa:bb:cc:00:00:01").unwrap(); let b = h.lookup("aa:bb:cc:00:00:01").unwrap();
assert_eq!(b.target, "new-target"); assert_eq!(b.target, "new-target");
@@ -241,7 +215,7 @@ mod tests {
fn remove_works_and_reports_outcome() { fn remove_works_and_reports_outcome() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path()); let h = HostBindings::load_or_default(dir.path());
h.upsert("aa:bb:cc:00:00:01", "x", "", np()); 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"));
assert!(!h.remove("aa:bb:cc:00:00:01")); // already gone assert!(!h.remove("aa:bb:cc:00:00:01")); // already gone
assert!(h.is_empty()); assert!(h.is_empty());
@@ -251,44 +225,11 @@ mod tests {
fn round_trip_persists_to_disk() { fn round_trip_persists_to_disk() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path()); let h = HostBindings::load_or_default(dir.path());
h.upsert("aa:bb:cc:00:00:01", "ubuntu-linux", "rack-3", np()); h.upsert("aa:bb:cc:00:00:01", "ubuntu-linux", "rack-3");
h.upsert("aa:bb:cc:00:00:02", "_local", "tom-laptop", np()); h.upsert("aa:bb:cc:00:00:02", "_local", "tom-laptop");
drop(h); drop(h);
let h2 = HostBindings::load_or_default(dir.path()); let h2 = HostBindings::load_or_default(dir.path());
assert_eq!(h2.len(), 2); assert_eq!(h2.len(), 2);
assert_eq!(h2.lookup("aa:bb:cc:00:00:02").unwrap().target, "_local"); assert_eq!(h2.lookup("aa:bb:cc:00:00:02").unwrap().target, "_local");
} }
#[test]
fn profile_round_trips_to_disk() {
let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path());
let prof = DeployProfile {
auto_hostname: Some("node-7".into()),
auto_ip: Some("10.0.0.7".into()),
unattended_file: Some("ubuntu-ks".into()),
};
h.upsert("aa:bb:cc:00:00:09", "ubuntu-linux", "lab", prof);
drop(h);
let h2 = HostBindings::load_or_default(dir.path());
let b = h2.lookup("aa:bb:cc:00:00:09").unwrap();
assert_eq!(b.profile.auto_hostname.as_deref(), Some("node-7"));
assert_eq!(b.profile.auto_ip.as_deref(), Some("10.0.0.7"));
assert_eq!(b.profile.unattended_file.as_deref(), Some("ubuntu-ks"));
}
#[test]
fn legacy_hosts_json_without_profile_still_loads() {
// A pre-v0.5.2 hosts.json has no profile keys at all.
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("hosts.json"),
br#"[{"mac":"aa:bb:cc:00:00:01","target":"_local","label":"old","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}]"#,
)
.unwrap();
let h = HostBindings::load_or_default(dir.path());
let b = h.lookup("aa:bb:cc:00:00:01").unwrap();
assert_eq!(b.target, "_local");
assert!(b.profile.is_empty());
}
} }
+3 -23
View File
@@ -3,41 +3,21 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
pub mod arch; pub mod arch;
pub mod auth;
pub mod boot_log;
pub mod boot_rules;
pub mod boot_tokens;
pub mod branding;
pub mod client; pub mod client;
pub mod config; pub mod config;
pub mod encoding;
pub mod error; pub mod error;
pub mod queue;
pub mod host_bindings; pub mod host_bindings;
pub mod log_bus; pub mod log_bus;
pub mod metrics; pub mod metrics;
pub mod notify;
pub mod profile;
pub mod queue;
pub mod saml;
pub mod settings; pub mod settings;
pub mod sso;
pub mod wol;
pub use arch::{ClientArch, DriverMode, FirmwareClass}; pub use arch::{ClientArch, FirmwareClass};
pub use auth::{AdminAccount, AdminPublic, AdminStore};
pub use boot_log::{BootEvent, BootLog};
pub use boot_rules::{BootRule, BootRulesConfig, BootRulesStore};
pub use boot_tokens::BootTokens;
pub use branding::{ext_for_mime, BrandingStore, LogoSlot, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES};
pub use client::{ClientEvent, ClientRegistry, ClientSnapshot}; pub use client::{ClientEvent, ClientRegistry, ClientSnapshot};
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig}; pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
pub use error::{Error, Result}; pub use error::{Error, Result};
pub use queue::{Gate, DeploymentQueue};
pub use host_bindings::{normalize_mac, HostBinding, HostBindings}; pub use host_bindings::{normalize_mac, HostBinding, HostBindings};
pub use log_bus::{LogBus, LogBusLayer, LogLine}; pub use log_bus::{LogBus, LogBusLayer, LogLine};
pub use metrics::{HttpRoute, Metrics}; pub use metrics::{HttpRoute, Metrics};
pub use notify::{NotifyConfig, NotifyKind, NotifyStore};
pub use profile::DeployProfile;
pub use queue::{DeploymentQueue, QueueEntry};
pub use saml::{IdpMetadata, SamlError, SpParams, VerifiedPrincipal, VerifiedResponse};
pub use settings::{Settings, SettingsStore, TimeoutAction}; pub use settings::{Settings, SettingsStore, TimeoutAction};
pub use sso::{SsoConfig, SsoLoginInfo, SsoStore};
+11 -62
View File
@@ -87,9 +87,7 @@ impl Metrics {
} }
pub fn record_tftp_err(&self) { pub fn record_tftp_err(&self) {
self.inner self.inner.tftp_transfers_err.fetch_add(1, Ordering::Relaxed);
.tftp_transfers_err
.fetch_add(1, Ordering::Relaxed);
} }
// ── HTTP ─────────────────────────────────────────────────────────── // ── HTTP ───────────────────────────────────────────────────────────
@@ -183,10 +181,7 @@ impl Metrics {
"", "",
); );
let _ = writeln!( let _ = writeln!(out, "# HELP openpxe_tftp_transfers_total TFTP transfers, by status.");
out,
"# HELP openpxe_tftp_transfers_total TFTP transfers, by status."
);
let _ = writeln!(out, "# TYPE openpxe_tftp_transfers_total counter"); let _ = writeln!(out, "# TYPE openpxe_tftp_transfers_total counter");
let _ = writeln!( let _ = writeln!(
out, out,
@@ -206,10 +201,7 @@ impl Metrics {
"", "",
); );
let _ = writeln!( let _ = writeln!(out, "# HELP openpxe_http_requests_total HTTP requests served, by route family.");
out,
"# HELP openpxe_http_requests_total HTTP requests served, by route family."
);
let _ = writeln!(out, "# TYPE openpxe_http_requests_total counter"); let _ = writeln!(out, "# TYPE openpxe_http_requests_total counter");
for (label, counter) in [ for (label, counter) in [
("boot_script", &i.http_boot_script), ("boot_script", &i.http_boot_script),
@@ -226,53 +218,14 @@ impl Metrics {
} }
// Gauges. // Gauges.
write_gauge( write_gauge(&mut out, "openpxe_iso_count", "ISOs currently registered (local + NFS).", i.iso_count.load(Ordering::Relaxed), "");
&mut out, write_gauge(&mut out, "openpxe_client_count", "PXE clients seen this process lifetime.", i.client_count.load(Ordering::Relaxed), "");
"openpxe_iso_count", write_gauge(&mut out, "openpxe_queue_count", "Clients currently waiting at the deployment queue.", i.queue_count.load(Ordering::Relaxed), "");
"ISOs currently registered (local + NFS).", write_gauge(&mut out, "openpxe_queue_imaging", "Clients currently imaging (queue + assigned target).", i.queue_imaging.load(Ordering::Relaxed), "");
i.iso_count.load(Ordering::Relaxed), write_gauge(&mut out, "openpxe_nfs_mounts_active", "NFS shares currently mounted.", i.nfs_mounts_active.load(Ordering::Relaxed), "");
"", write_gauge(&mut out, "openpxe_uptime_seconds", "Seconds since this OpenPXE instance started.", uptime_secs, "");
);
write_gauge(
&mut out,
"openpxe_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!( let _ = writeln!(out, "# HELP openpxe_build_info Build metadata. Always 1; the version is in the label.");
out,
"# HELP openpxe_build_info Build metadata. Always 1; the version is in the label."
);
let _ = writeln!(out, "# TYPE openpxe_build_info gauge"); let _ = writeln!(out, "# TYPE openpxe_build_info gauge");
let _ = writeln!(out, "openpxe_build_info{{version=\"{version}\"}} 1"); let _ = writeln!(out, "openpxe_build_info{{version=\"{version}\"}} 1");
@@ -306,11 +259,7 @@ mod tests {
m.record_http(HttpRoute::Api); m.record_http(HttpRoute::Api);
m.set_iso_count(3); m.set_iso_count(3);
let out = m.render("0.2.0", 42); let out = m.render("0.2.0", 42);
assert_eq!( assert_eq!(out.matches("# TYPE openpxe_dhcp_replies_total counter").count(), 1);
out.matches("# TYPE openpxe_dhcp_replies_total counter")
.count(),
1
);
assert_eq!(out.matches("# TYPE openpxe_iso_count gauge").count(), 1); assert_eq!(out.matches("# TYPE openpxe_iso_count gauge").count(), 1);
assert!(out.contains("openpxe_dhcp_replies_total{arch=\"uefi\"} 1")); assert!(out.contains("openpxe_dhcp_replies_total{arch=\"uefi\"} 1"));
assert!(out.contains("openpxe_dhcp_replies_total{arch=\"bios\"} 1")); assert!(out.contains("openpxe_dhcp_replies_total{arch=\"bios\"} 1"));
-365
View File
@@ -1,365 +0,0 @@
//! Webhook / email notification configuration.
//!
//! v0.5.0: OpenPXE can ping a chat webhook or send an email when
//! something noteworthy happens (a machine PXE-booted an image, a
//! deployment was assigned, a WoL was sent). One active provider at a
//! time, chosen by `kind` — dead-simple for an L1 tech: pick Slack,
//! paste the incoming-webhook URL, done.
//!
//! This module owns only the *configuration* (validation + persistence
//! to `<work_dir>/notify.json`). The actual sending — HTTP POST for the
//! chat providers, SMTP for email — lives in the http-api crate, which
//! already carries an HTTP client and the SMTP dependency. Keeping the
//! network I/O out of `core` matches how `BrandingStore`/`SsoStore`
//! stay pure config stores.
//!
//! Secrets note: the SMTP password is persisted in `notify.json`
//! alongside the rest of the config (0644 like the other state files).
//! It is never echoed back through the API — the snapshot used for the
//! GET response blanks it (see `Self::redacted`).
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use crate::{Error, Result};
const MAX_URL_LEN: usize = 2048;
const MAX_FIELD_LEN: usize = 512;
/// Which notification transport is active.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NotifyKind {
/// Slack incoming webhook (`{ "text": ... }`).
#[default]
Slack,
/// Discord webhook (`{ "content": ... }`).
Discord,
/// Microsoft Teams incoming webhook (legacy MessageCard JSON).
Teams,
/// Email via SMTP.
Smtp,
}
impl NotifyKind {
/// True when this kind drives a chat webhook (POST a JSON body to a
/// single URL) rather than SMTP.
#[must_use]
pub fn is_webhook(self) -> bool {
matches!(self, Self::Slack | Self::Discord | Self::Teams)
}
}
/// Operator-configurable notification settings. Single provider active
/// at a time; the inactive fields are kept so switching providers
/// doesn't wipe the other one's values.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NotifyConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub kind: NotifyKind,
/// Incoming-webhook URL for Slack / Discord / Teams.
#[serde(default)]
pub webhook_url: String,
// ── SMTP fields (used when kind == Smtp) ──
#[serde(default)]
pub smtp_host: String,
#[serde(default = "default_smtp_port")]
pub smtp_port: u16,
#[serde(default)]
pub smtp_username: String,
#[serde(default)]
pub smtp_password: String,
/// `From:` address. Falls back to `smtp_username` when blank.
#[serde(default)]
pub smtp_from: String,
/// `To:` address (single recipient — keep it simple).
#[serde(default)]
pub smtp_to: String,
/// Use implicit TLS (port 465). When false we use STARTTLS on the
/// configured port (587 typical). Either way the connection is
/// encrypted — we never offer plaintext SMTP.
#[serde(default)]
pub smtp_implicit_tls: bool,
}
fn default_smtp_port() -> u16 {
587
}
impl NotifyConfig {
/// True when enabled and the active provider has the fields it
/// needs to actually send.
#[must_use]
pub fn is_usable(&self) -> bool {
if !self.enabled {
return false;
}
if self.kind.is_webhook() {
!self.webhook_url.trim().is_empty()
} else {
!self.smtp_host.trim().is_empty() && !self.smtp_to.trim().is_empty()
}
}
/// A copy safe to return over the API: the SMTP password is blanked
/// (replaced with a non-empty sentinel only when one is set, so the
/// UI can show "configured" without leaking it).
#[must_use]
pub fn redacted(&self) -> NotifyConfig {
let mut c = self.clone();
if !c.smtp_password.is_empty() {
c.smtp_password = SECRET_SENTINEL.to_string();
}
c
}
}
/// Returned by the API in place of a stored password. When the UI PUTs
/// this value back unchanged we keep the existing password rather than
/// overwriting it with the sentinel.
pub const SECRET_SENTINEL: &str = "__keep__";
/// In-memory + on-disk notification config registry.
#[derive(Debug, Clone)]
pub struct NotifyStore {
path: Arc<PathBuf>,
inner: Arc<RwLock<NotifyConfig>>,
}
impl NotifyStore {
#[must_use]
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("notify.json");
let cfg = match std::fs::read_to_string(&path) {
Ok(text) => serde_json::from_str::<NotifyConfig>(&text).unwrap_or_else(|e| {
tracing::warn!(
target: "openpxe::notify",
"notify.json unreadable ({e}); starting with defaults"
);
NotifyConfig::default()
}),
Err(_) => NotifyConfig::default(),
};
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(cfg)),
}
}
#[must_use]
pub fn snapshot(&self) -> NotifyConfig {
self.inner.read().clone()
}
/// Replace the whole config. `incoming.smtp_password == SECRET_SENTINEL`
/// is treated as "keep the existing password" so the UI never has to
/// round-trip the real secret.
pub fn replace(&self, mut incoming: NotifyConfig) -> Result<NotifyConfig> {
incoming.webhook_url = incoming.webhook_url.trim().to_string();
incoming.smtp_host = incoming.smtp_host.trim().to_string();
incoming.smtp_username = incoming.smtp_username.trim().to_string();
incoming.smtp_from = incoming.smtp_from.trim().to_string();
incoming.smtp_to = incoming.smtp_to.trim().to_string();
// Preserve the stored password when the UI sends the sentinel.
if incoming.smtp_password == SECRET_SENTINEL {
incoming
.smtp_password
.clone_from(&self.inner.read().smtp_password);
}
// Length caps.
if incoming.webhook_url.len() > MAX_URL_LEN {
return Err(Error::Invalid(format!(
"webhook URL exceeds {MAX_URL_LEN}-char cap"
)));
}
for (name, v) in [
("smtp_host", &incoming.smtp_host),
("smtp_username", &incoming.smtp_username),
("smtp_from", &incoming.smtp_from),
("smtp_to", &incoming.smtp_to),
] {
if v.len() > MAX_FIELD_LEN {
return Err(Error::Invalid(format!(
"{name} exceeds {MAX_FIELD_LEN}-char cap"
)));
}
}
// Validate the active provider only when enabling.
if incoming.enabled {
if incoming.kind.is_webhook() {
if incoming.webhook_url.is_empty() {
return Err(Error::Invalid(
"a webhook URL is required to enable chat notifications".into(),
));
}
if !incoming.webhook_url.starts_with("https://")
&& !incoming.webhook_url.starts_with("http://")
{
return Err(Error::Invalid(
"webhook URL must start with http:// or https://".into(),
));
}
} else {
if incoming.smtp_host.is_empty() {
return Err(Error::Invalid(
"SMTP host is required to enable email notifications".into(),
));
}
if incoming.smtp_to.is_empty() {
return Err(Error::Invalid(
"a recipient (To) is required to enable email notifications".into(),
));
}
if incoming.smtp_port == 0 {
return Err(Error::Invalid("SMTP port must be non-zero".into()));
}
}
}
{
let mut g = self.inner.write();
*g = incoming.clone();
}
self.persist();
tracing::info!(
target: "openpxe::notify",
enabled = incoming.enabled, kind = ?incoming.kind,
"notification configuration updated"
);
Ok(incoming)
}
fn persist(&self) {
let snap = self.inner.read().clone();
let body = match serde_json::to_vec_pretty(&snap) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::notify", "serialize notify.json: {e}");
return;
}
};
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = self.path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::notify", "write notify.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::warn!(target: "openpxe::notify", "rename notify.json: {e}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn default_disabled_not_usable() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
assert!(!s.snapshot().enabled);
assert!(!s.snapshot().is_usable());
}
#[test]
fn slack_requires_url_when_enabled() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
let r = s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Slack,
..Default::default()
});
assert!(matches!(r, Err(Error::Invalid(_))));
s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Slack,
webhook_url: "https://hooks.slack.com/services/XXX".into(),
..Default::default()
})
.unwrap();
assert!(s.snapshot().is_usable());
}
#[test]
fn smtp_requires_host_and_recipient() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
let r = s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Smtp,
smtp_host: "smtp.example.com".into(),
..Default::default()
});
assert!(
matches!(r, Err(Error::Invalid(_))),
"missing recipient should reject"
);
s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Smtp,
smtp_host: "smtp.example.com".into(),
smtp_port: 587,
smtp_to: "[email protected]".into(),
smtp_from: "[email protected]".into(),
..Default::default()
})
.unwrap();
assert!(s.snapshot().is_usable());
}
#[test]
fn password_sentinel_preserves_stored_secret() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Smtp,
smtp_host: "smtp.example.com".into(),
smtp_port: 587,
smtp_to: "[email protected]".into(),
smtp_password: "s3cret".into(),
..Default::default()
})
.unwrap();
// Redacted snapshot hides the password behind the sentinel.
assert_eq!(s.snapshot().redacted().smtp_password, SECRET_SENTINEL);
// PUTting the sentinel back keeps the real password.
s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Smtp,
smtp_host: "smtp.example.com".into(),
smtp_port: 587,
smtp_to: "[email protected]".into(),
smtp_password: SECRET_SENTINEL.into(),
..Default::default()
})
.unwrap();
assert_eq!(s.snapshot().smtp_password, "s3cret");
}
#[test]
fn webhook_url_scheme_enforced() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
let r = s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Discord,
webhook_url: "ftp://example.com/hook".into(),
..Default::default()
});
assert!(matches!(r, Err(Error::Invalid(_))));
}
}
-122
View File
@@ -1,122 +0,0 @@
//! Per-host deployment profile.
//!
//! v0.5.2: a small, optional bundle of "what should this machine do when
//! it images" attached to either a pinned host binding ([`crate::HostBinding`])
//! or a queued device ([`crate::QueueEntry`]). All three fields are
//! optional and independent:
//!
//! * `auto_hostname` — substituted into the served unattended answer file
//! (`{{HOSTNAME}}`) so the installer sets the machine name.
//! * `auto_ip` — substituted as `{{IP}}`. OpenPXE is a DHCP **proxy** and
//! does not hand out leases, so this is applied by the installer as a
//! static-network directive inside the answer file, not by DHCP.
//! * `unattended_file` — the id of an uploaded file in the unattended
//! store (Kickstart / Preseed / Autoinstall / Windows answer file). When
//! set, the boot chain injects the appropriate kernel argument so the
//! install runs unattended.
use serde::{Deserialize, Serialize};
/// Optional deployment hints carried on a host pin or a queue entry.
///
/// The fields are flattened into `HostBinding` / `QueueEntry` on the wire
/// (so existing JSON stays compatible via `#[serde(default)]`); this type
/// is the in-code bundle the boot chain consumes.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeployProfile {
/// Hostname to set on the imaged machine (`{{HOSTNAME}}`). Empty/None
/// leaves the installer default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_hostname: Option<String>,
/// Static IPv4/IPv6 the installer should configure (`{{IP}}`). Stored
/// as a free-form string — validated lightly at the HTTP layer.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_ip: Option<String>,
/// Id of an uploaded file in the unattended store. Empty/None means
/// "no unattended install — boot interactively".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unattended_file: Option<String>,
}
/// Cap on the stored hostname / IP strings — generous for any real value
/// but bounds what an operator can stuff into the JSON.
pub const MAX_PROFILE_FIELD_LEN: usize = 255;
impl DeployProfile {
/// True when nothing is set — lets call sites skip work entirely.
#[must_use]
pub fn is_empty(&self) -> bool {
self.auto_hostname.is_none() && self.auto_ip.is_none() && self.unattended_file.is_none()
}
/// True when an unattended file is selected (drives boot-chain injection).
#[must_use]
pub fn has_unattended(&self) -> bool {
self.unattended_file
.as_deref()
.is_some_and(|s| !s.trim().is_empty())
}
/// Normalise: trim every field and collapse empty strings to `None`
/// so persisted JSON never carries `""` for an unset value.
#[must_use]
pub fn normalized(mut self) -> Self {
fn clean(v: Option<String>) -> Option<String> {
v.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.map(|s| s.chars().take(MAX_PROFILE_FIELD_LEN).collect())
}
self.auto_hostname = clean(self.auto_hostname);
self.auto_ip = clean(self.auto_ip);
self.unattended_file = clean(self.unattended_file);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_profile_is_empty() {
assert!(DeployProfile::default().is_empty());
assert!(!DeployProfile::default().has_unattended());
}
#[test]
fn normalize_trims_and_nulls_empty() {
let p = DeployProfile {
auto_hostname: Some(" node-7 ".into()),
auto_ip: Some(" ".into()),
unattended_file: Some(String::new()),
}
.normalized();
assert_eq!(p.auto_hostname.as_deref(), Some("node-7"));
assert_eq!(p.auto_ip, None);
assert_eq!(p.unattended_file, None);
assert!(!p.is_empty());
}
#[test]
fn has_unattended_detects_real_id() {
let p = DeployProfile {
unattended_file: Some("ubuntu-ks".into()),
..Default::default()
};
assert!(p.has_unattended());
}
#[test]
fn long_field_is_capped() {
let long = "a".repeat(1000);
let p = DeployProfile {
auto_hostname: Some(long),
..Default::default()
}
.normalized();
assert_eq!(
p.auto_hostname.as_deref().map(str::len),
Some(MAX_PROFILE_FIELD_LEN)
);
}
}
+24 -60
View File
@@ -1,16 +1,16 @@
//! Queued Deployment queue. //! Queued Deployment queue.
//! //!
//! When a client selects "Queued Deployment" at the PXE menu, iPXE POSTs to //! When a client selects "Queued Deployment" at the PXE menu, iPXE POSTs to
//! `/api/queue/join` and receives a queue position. It then enters a poll //! `/api/queue/join` and receives a gate position. It then enters a poll
//! loop hitting `/api/queue/poll/<id>`; the server holds the request open //! 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/queue`) and issues //! The WebUI shows the queue (`GET /api/gate`) and issues
//! `POST /api/queue/assign { iso_id, entry_ids: [...] }` to launch a single //! `POST /api/queue/assign { iso_id, entry_ids: [...] }` to launch a single
//! ISO across many queued clients at once. Every waiting machine receives //! ISO across many gated clients at once. This is the "horse-race gate"
//! the assignment without operator visits at the rack. //! UX the user asked for — every horse leaves the line simultaneously.
use parking_lot::RwLock; use parking_lot::RwLock;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -21,14 +21,13 @@ use time::OffsetDateTime;
use tokio::sync::Notify; use tokio::sync::Notify;
use uuid::Uuid; use uuid::Uuid;
use crate::profile::DeployProfile;
use crate::ClientArch; use crate::ClientArch;
/// Per-client queue state visible to the WebUI. /// Per-gate state visible to the WebUI.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueueEntry { pub struct Gate {
pub id: String, pub id: String,
/// 1-based queue position — position 1 is whoever got there first. /// 1-based race-gate position — position 1 is whoever got there first.
pub position: u32, pub position: u32,
pub mac: String, pub mac: String,
pub ip: Option<IpAddr>, pub ip: Option<IpAddr>,
@@ -38,11 +37,6 @@ pub struct QueueEntry {
#[serde(with = "time::serde::rfc3339")] #[serde(with = "time::serde::rfc3339")]
pub last_poll_at: OffsetDateTime, pub last_poll_at: OffsetDateTime,
pub assigned_target: Option<String>, pub assigned_target: Option<String>,
/// v0.5.2: optional per-device deployment profile set via the queue
/// "Profile" button (auto hostname / IP / unattended file). Flattened
/// so the JSON stays flat alongside the other queue fields.
#[serde(default, flatten)]
pub profile: DeployProfile,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -55,15 +49,14 @@ struct QueueEntryInner {
joined_at: OffsetDateTime, joined_at: OffsetDateTime,
last_poll_at: OffsetDateTime, last_poll_at: OffsetDateTime,
assigned_target: Option<String>, assigned_target: Option<String>,
profile: DeployProfile,
/// Broadcast primitive that wakes the long-poll as soon as an /// Broadcast primitive that wakes the long-poll as soon as an
/// assignment lands — no polling on our side, no sleep-loops. /// assignment lands — no polling on our side, no sleep-loops.
notify: Arc<Notify>, notify: Arc<Notify>,
} }
impl QueueEntryInner { impl QueueEntryInner {
fn snapshot(&self) -> QueueEntry { fn snapshot(&self) -> Gate {
QueueEntry { Gate {
id: self.id.clone(), id: self.id.clone(),
position: self.position, position: self.position,
mac: self.mac.clone(), mac: self.mac.clone(),
@@ -72,7 +65,6 @@ impl QueueEntryInner {
joined_at: self.joined_at, joined_at: self.joined_at,
last_poll_at: self.last_poll_at, last_poll_at: self.last_poll_at,
assigned_target: self.assigned_target.clone(), assigned_target: self.assigned_target.clone(),
profile: self.profile.clone(),
} }
} }
} }
@@ -88,25 +80,21 @@ impl DeploymentQueue {
Arc::new(Self::default()) Arc::new(Self::default())
} }
/// Add a client to the queue. Returns the current queue snapshot. If the /// Add a client to the gate. Returns the new `Gate` snapshot. If the
/// MAC is already queued, the existing entry is returned unchanged — /// MAC is already queued, the existing gate is returned unchanged —
/// retrying iPXE clients don't duplicate their slot. /// retrying iPXE clients don't duplicate their slot.
pub fn join(&self, mac: &str, ip: Option<IpAddr>, arch: Option<ClientArch>) -> QueueEntry { pub fn join(&self, mac: &str, ip: Option<IpAddr>, arch: Option<ClientArch>) -> Gate {
let now = OffsetDateTime::now_utc(); let now = OffsetDateTime::now_utc();
let mut guard = self.inner.write(); let mut guard = self.inner.write();
if let Some(existing) = guard.values_mut().find(|g| g.mac == mac) { if let Some(existing) = guard.values_mut().find(|g| g.mac == mac) {
existing.last_poll_at = now; existing.last_poll_at = now;
if ip.is_some() { if ip.is_some() { existing.ip = ip; }
existing.ip = ip; if arch.is_some() { existing.arch = arch; }
}
if arch.is_some() {
existing.arch = arch;
}
return existing.snapshot(); return existing.snapshot();
} }
// Queue position = max(position) + 1, or 1 if empty. // Race position = max(position) + 1, or 1 if empty.
let next_pos = guard.values().map(|g| g.position).max().unwrap_or(0) + 1; let next_pos = guard.values().map(|g| g.position).max().unwrap_or(0) + 1;
let id = Uuid::new_v4().to_string(); let id = Uuid::new_v4().to_string();
let inner = QueueEntryInner { let inner = QueueEntryInner {
@@ -118,7 +106,6 @@ impl DeploymentQueue {
joined_at: now, joined_at: now,
last_poll_at: now, last_poll_at: now,
assigned_target: None, assigned_target: None,
profile: DeployProfile::default(),
notify: Arc::new(Notify::new()), notify: Arc::new(Notify::new()),
}; };
let snap = inner.snapshot(); let snap = inner.snapshot();
@@ -126,47 +113,24 @@ impl DeploymentQueue {
snap snap
} }
/// Look up the `Notify` primitive for a given queue entry id, for long-polling. /// Look up the `Notify` primitive for a given gate id, for long-polling.
#[must_use] #[must_use]
pub fn notifier(&self, entry_id: &str) -> Option<Arc<Notify>> { pub fn notifier(&self, entry_id: &str) -> Option<Arc<Notify>> {
self.inner.read().get(entry_id).map(|g| g.notify.clone()) self.inner.read().get(entry_id).map(|g| g.notify.clone())
} }
/// Update the last-poll timestamp (keeps the queue's "live" indicator /// Update the last-poll timestamp (keeps the gate's "live" indicator
/// fresh in the UI) and return the current snapshot. Returns None if /// fresh in the UI) and return the current snapshot. Returns None if
/// the entry was released/expired between requests. /// the gate was released/expired between requests.
pub fn touch(&self, entry_id: &str) -> Option<QueueEntry> { 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(entry_id)?; let g = guard.get_mut(entry_id)?;
g.last_poll_at = OffsetDateTime::now_utc(); g.last_poll_at = OffsetDateTime::now_utc();
Some(g.snapshot()) Some(g.snapshot())
} }
/// Operator sets (or clears) the deployment profile for a queued /// Operator assigns an ISO entry (boot_entry id) to one or more gates.
/// device via the WebUI "Profile" button. Returns the updated /// Returns the number of gates that were updated. Gates not in the
/// snapshot, or `None` if the entry has since been released.
pub fn set_profile(&self, entry_id: &str, profile: DeployProfile) -> Option<QueueEntry> {
let mut guard = self.inner.write();
let g = guard.get_mut(entry_id)?;
g.profile = profile.normalized();
Some(g.snapshot())
}
/// Look up the deployment profile for a queued MAC, if any. Used by
/// the boot chain to inject an unattended file / template the
/// hostname + IP when an assigned device chains to its target.
#[must_use]
pub fn profile_for_mac(&self, mac: &str) -> Option<DeployProfile> {
let guard = self.inner.read();
guard
.values()
.find(|g| g.mac == mac)
.map(|g| g.profile.clone())
.filter(|p| !p.is_empty())
}
/// Operator assigns an ISO entry (boot_entry id) to one or more clients.
/// Returns the number of queue entries that were updated. Entries not in the
/// queue are silently skipped. /// queue are silently skipped.
pub fn assign(&self, entry_ids: &[String], target: &str) -> usize { pub fn assign(&self, entry_ids: &[String], target: &str) -> usize {
let mut guard = self.inner.write(); let mut guard = self.inner.write();
@@ -181,9 +145,9 @@ impl DeploymentQueue {
updated updated
} }
/// Remove a queue entry and return its final snapshot. Called after the client /// Remove a gate and return its final snapshot. Called after the client
/// has successfully chained onto its assignment. /// has successfully chained onto its assignment.
pub fn release(&self, entry_id: &str) -> Option<QueueEntry> { pub fn release(&self, entry_id: &str) -> Option<Gate> {
let mut guard = self.inner.write(); let mut guard = self.inner.write();
let g = guard.remove(entry_id)?; let g = guard.remove(entry_id)?;
g.notify.notify_waiters(); g.notify.notify_waiters();
@@ -198,7 +162,7 @@ impl DeploymentQueue {
} }
#[must_use] #[must_use]
pub fn list(&self) -> Vec<QueueEntry> { pub fn list(&self) -> Vec<Gate> {
let guard = self.inner.read(); let guard = self.inner.read();
let mut v: Vec<_> = guard.values().map(QueueEntryInner::snapshot).collect(); let mut v: Vec<_> = guard.values().map(QueueEntryInner::snapshot).collect();
v.sort_by_key(|g| g.position); v.sort_by_key(|g| g.position);
-159
View File
@@ -1,159 +0,0 @@
//! AuthnRequest construction + HTTP-Redirect binding encoding.
//!
//! For SP-initiated login we build an `<AuthnRequest>`, then encode it for the
//! HTTP-Redirect binding: raw DEFLATE (RFC 1951) → base64 → percent-encode,
//! appended as the `SAMLRequest` query parameter. AuthnRequests are sent
//! unsigned in this release (the IdP must not require client signatures).
use std::io::Write as _;
use base64::Engine;
use flate2::write::DeflateEncoder;
use flate2::Compression;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use super::{SamlError, SpParams};
use crate::encoding::{pct_encode, xml_escape};
const NS_PROTOCOL: &str = "urn:oasis:names:tc:SAML:2.0:protocol";
const NS_ASSERTION: &str = "urn:oasis:names:tc:SAML:2.0:assertion";
const NAMEID_EMAIL: &str = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
const BINDING_POST: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
/// A built AuthnRequest, ready to redirect the browser to the IdP.
#[derive(Debug, Clone)]
pub struct AuthnRequest {
/// The request `ID` — the caller records this so the matching response's
/// `InResponseTo` can be correlated (replay/CSRF protection).
pub id: String,
/// The full IdP URL to 302 the browser to (includes `SAMLRequest` and,
/// when supplied, `RelayState`).
pub location: String,
}
/// Build an AuthnRequest targeting `idp_sso_url` and encode it for the
/// HTTP-Redirect binding. `relay_state`, if given, round-trips back to us via
/// the response (we use it to send the operator to their intended page).
pub fn build(
sp: &SpParams,
idp_sso_url: &str,
relay_state: Option<&str>,
) -> Result<AuthnRequest, SamlError> {
let id = format!("_{}", uuid::Uuid::new_v4().simple());
let issue_instant = OffsetDateTime::now_utc()
.replace_nanosecond(0)
.unwrap_or_else(|_| OffsetDateTime::now_utc())
.format(&Rfc3339)
.map_err(|e| SamlError::Timestamp(e.to_string()))?;
let xml = format!(
r#"<samlp:AuthnRequest xmlns:samlp="{NS_PROTOCOL}" xmlns:saml="{NS_ASSERTION}" ID="{id}" Version="2.0" IssueInstant="{instant}" Destination="{dest}" ProtocolBinding="{BINDING_POST}" AssertionConsumerServiceURL="{acs}"><saml:Issuer>{issuer}</saml:Issuer><samlp:NameIDPolicy Format="{NAMEID_EMAIL}" AllowCreate="true"/></samlp:AuthnRequest>"#,
instant = issue_instant,
dest = xml_escape(idp_sso_url),
acs = xml_escape(&sp.acs_url),
issuer = xml_escape(&sp.entity_id),
);
let encoded = deflate_base64(&xml)?;
let sep = if idp_sso_url.contains('?') { '&' } else { '?' };
let mut location = format!("{idp_sso_url}{sep}SAMLRequest={}", pct_encode(&encoded));
if let Some(rs) = relay_state {
location.push_str("&RelayState=");
location.push_str(&pct_encode(rs));
}
Ok(AuthnRequest { id, location })
}
/// Raw-DEFLATE then base64 — the HTTP-Redirect binding's `SAMLRequest` payload.
fn deflate_base64(xml: &str) -> Result<String, SamlError> {
let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
enc.write_all(xml.as_bytes())
.and_then(|()| enc.try_finish())
.map_err(|e| SamlError::Xml(format!("deflate: {e}")))?;
let compressed = enc
.finish()
.map_err(|e| SamlError::Xml(format!("deflate: {e}")))?;
Ok(base64::engine::general_purpose::STANDARD.encode(compressed))
}
// `pct_encode` + `xml_escape` now live in `openpxe_core::encoding` (v0.5.4)
// — imported above.
#[cfg(test)]
mod tests {
use super::*;
use flate2::read::DeflateDecoder;
use std::io::Read;
fn sp() -> SpParams {
SpParams {
entity_id: "https://pxe.example.com".into(),
acs_url: "https://pxe.example.com/api/sso/acs".into(),
}
}
fn pct_decode(s: &str) -> Vec<u8> {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = (bytes[i + 1] as char).to_digit(16).unwrap();
let lo = (bytes[i + 2] as char).to_digit(16).unwrap();
out.push((hi * 16 + lo) as u8);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
out
}
#[test]
fn id_is_ncname_and_location_has_request() {
let req = build(&sp(), "https://idp.example.com/sso", Some("/dashboard")).unwrap();
assert!(req.id.starts_with('_'));
assert!(req
.location
.starts_with("https://idp.example.com/sso?SAMLRequest="));
assert!(req.location.contains("&RelayState=%2Fdashboard"));
}
#[test]
fn redirect_payload_round_trips_to_our_authn_request() {
let req = build(&sp(), "https://idp.example.com/sso", None).unwrap();
// Pull SAMLRequest value out of the query string.
let q = req.location.split("SAMLRequest=").nth(1).unwrap();
let val = q.split('&').next().unwrap();
let compressed = base64::engine::general_purpose::STANDARD
.decode(pct_decode(val))
.unwrap();
let mut inflate = DeflateDecoder::new(&compressed[..]);
let mut xml = String::new();
inflate.read_to_string(&mut xml).unwrap();
let doc = roxmltree::Document::parse(&xml).unwrap();
let root = doc.root_element();
assert_eq!(root.tag_name().name(), "AuthnRequest");
assert_eq!(root.attribute("ID").unwrap(), req.id);
assert_eq!(
root.attribute("AssertionConsumerServiceURL").unwrap(),
"https://pxe.example.com/api/sso/acs"
);
let issuer = root
.descendants()
.find(|n| n.tag_name().name() == "Issuer")
.unwrap();
assert_eq!(issuer.text().unwrap(), "https://pxe.example.com");
}
#[test]
fn existing_query_uses_ampersand_separator() {
let req = build(&sp(), "https://idp.example.com/sso?foo=bar", None).unwrap();
assert!(req.location.contains("?foo=bar&SAMLRequest="));
}
}
-228
View File
@@ -1,228 +0,0 @@
//! IdP metadata parsing + SP metadata generation.
//!
//! We parse only what the SP flow needs: the IdP Entity ID, its
//! `SingleSignOnService` endpoints (HTTP-Redirect / HTTP-POST), and the
//! X.509 signing certificate(s). Everything else in the document is ignored.
use base64::Engine;
use super::{SamlError, SpParams};
use crate::encoding::xml_escape;
/// SAML 2.0 binding URIs.
pub const BINDING_REDIRECT: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect";
pub const BINDING_POST: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
/// The subset of an IdP's `EntityDescriptor` the SP flow consumes.
#[derive(Debug, Clone)]
pub struct IdpMetadata {
/// The IdP's Entity ID — we require incoming assertions to be issued by it.
pub entity_id: String,
/// SSO endpoint for the HTTP-Redirect binding (where we send AuthnRequests).
pub sso_redirect_url: Option<String>,
/// SSO endpoint for the HTTP-POST binding (fallback target).
pub sso_post_url: Option<String>,
/// DER-encoded X.509 signing certificate(s). More than one appears during
/// key rotation; verification tries each.
pub signing_certs_der: Vec<Vec<u8>>,
}
impl IdpMetadata {
/// Parse an IdP `EntityDescriptor` document.
///
/// Robust to namespace-prefix variation (matches on local element names),
/// since IdPs disagree on prefixes (`md:`, `ns0:`, default, …).
pub fn parse(xml: &str) -> Result<Self, SamlError> {
let doc = roxmltree::Document::parse(xml).map_err(|e| SamlError::Xml(e.to_string()))?;
let root = doc.root_element();
// The signing IDP descriptor. Some metadata wraps multiple
// descriptors (AA, SP) in one document; we want IDPSSODescriptor.
let idp_desc = root
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "IDPSSODescriptor")
.ok_or_else(|| SamlError::Metadata("IDPSSODescriptor".into()))?;
// Entity ID lives on the EntityDescriptor (root, or an ancestor of the
// IDPSSODescriptor when several are nested).
let entity_id = idp_desc
.ancestors()
.find_map(|n| {
if n.tag_name().name() == "EntityDescriptor" {
n.attribute("entityID")
} else {
None
}
})
.or_else(|| root.attribute("entityID"))
.map(str::to_owned)
.ok_or_else(|| SamlError::Metadata("entityID".into()))?;
let mut sso_redirect_url = None;
let mut sso_post_url = None;
for sso in idp_desc
.children()
.filter(|n| n.is_element() && n.tag_name().name() == "SingleSignOnService")
{
let binding = sso.attribute("Binding").unwrap_or("");
let location = sso.attribute("Location").map(str::to_owned);
match binding {
BINDING_REDIRECT if sso_redirect_url.is_none() => sso_redirect_url = location,
BINDING_POST if sso_post_url.is_none() => sso_post_url = location,
_ => {}
}
}
// Signing certs: KeyDescriptor with use="signing" or no use attribute
// (a bare KeyDescriptor is valid for both signing and encryption).
let mut signing_certs_der = Vec::new();
for kd in idp_desc
.children()
.filter(|n| n.is_element() && n.tag_name().name() == "KeyDescriptor")
{
match kd.attribute("use") {
Some("signing") | None => {}
Some(_) => continue, // encryption-only key — skip
}
for cert_node in kd
.descendants()
.filter(|n| n.is_element() && n.tag_name().name() == "X509Certificate")
{
let b64: String = node_text(&cert_node)
.chars()
.filter(|c| !c.is_whitespace())
.collect();
if b64.is_empty() {
continue;
}
let der = base64::engine::general_purpose::STANDARD
.decode(b64.as_bytes())
.map_err(|e| SamlError::Base64(e.to_string()))?;
signing_certs_der.push(der);
}
}
if signing_certs_der.is_empty() {
return Err(SamlError::NoSigningCert);
}
Ok(Self {
entity_id,
sso_redirect_url,
sso_post_url,
signing_certs_der,
})
}
/// Preferred SSO destination for an outbound AuthnRequest: HTTP-Redirect
/// if advertised, otherwise HTTP-POST.
pub fn sso_destination(&self) -> Option<&str> {
self.sso_redirect_url
.as_deref()
.or(self.sso_post_url.as_deref())
}
}
/// Build our SP `EntityDescriptor` XML so an IdP admin can import OpenPXE as a
/// relying party. Advertises the ACS URL (HTTP-POST binding) and an emailAddress
/// NameID format — matching what the response path expects.
pub fn build_sp_metadata(sp: &SpParams) -> String {
let entity = xml_escape(&sp.entity_id);
let acs = xml_escape(&sp.acs_url);
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{entity}">
<SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
<AssertionConsumerService Binding="{BINDING_POST}" Location="{acs}" index="0" isDefault="true"/>
</SPSSODescriptor>
</EntityDescriptor>
"#
)
}
/// Collect the concatenated text of an element's direct text children.
fn node_text(n: &roxmltree::Node<'_, '_>) -> String {
n.children()
.filter(roxmltree::Node::is_text)
.filter_map(|c| c.text())
.collect()
}
// `xml_escape` now lives in `openpxe_core::encoding` (v0.5.4) — imported above.
#[cfg(test)]
mod tests {
use super::*;
// A trimmed-down Keycloak-style IdP descriptor (cert body is a stand-in;
// signing tests build real certs in the parent module's tests).
const SAMPLE: &str = r#"<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
entityID="https://idp.example.com/realms/fleet">
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo><ds:X509Data><ds:X509Certificate>
QUJDREVG
</ds:X509Certificate></ds:X509Data></ds:KeyInfo>
</md:KeyDescriptor>
<md:KeyDescriptor use="encryption">
<ds:KeyInfo><ds:X509Data><ds:X509Certificate>WlpaWg==</ds:X509Certificate></ds:X509Data></ds:KeyInfo>
</md:KeyDescriptor>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="https://idp.example.com/realms/fleet/protocol/saml"/>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://idp.example.com/realms/fleet/protocol/saml"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>"#;
#[test]
fn parses_entity_sso_and_signing_cert() {
let m = IdpMetadata::parse(SAMPLE).unwrap();
assert_eq!(m.entity_id, "https://idp.example.com/realms/fleet");
assert_eq!(
m.sso_redirect_url.as_deref(),
Some("https://idp.example.com/realms/fleet/protocol/saml")
);
assert!(m.sso_post_url.is_some());
// Only the signing KeyDescriptor's cert is collected (ABCDEF), not the
// encryption one (ZZZZ).
assert_eq!(m.signing_certs_der.len(), 1);
assert_eq!(m.signing_certs_der[0], b"ABCDEF");
}
#[test]
fn missing_signing_cert_is_rejected() {
let xml = r#"<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="x">
<IDPSSODescriptor>
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://x/sso"/>
</IDPSSODescriptor></EntityDescriptor>"#;
assert!(matches!(
IdpMetadata::parse(xml),
Err(SamlError::NoSigningCert)
));
}
#[test]
fn missing_idp_descriptor_is_rejected() {
let xml = r#"<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="x"></EntityDescriptor>"#;
assert!(matches!(
IdpMetadata::parse(xml),
Err(SamlError::Metadata(_))
));
}
#[test]
fn sp_metadata_contains_entity_and_acs() {
let sp = SpParams {
entity_id: "https://pxe.example.com".into(),
acs_url: "https://pxe.example.com/api/sso/acs".into(),
};
let xml = build_sp_metadata(&sp);
assert!(xml.contains(r#"entityID="https://pxe.example.com""#));
assert!(xml.contains("https://pxe.example.com/api/sso/acs"));
assert!(xml.contains(BINDING_POST));
// Must be well-formed.
roxmltree::Document::parse(&xml).unwrap();
}
}
-92
View File
@@ -1,92 +0,0 @@
//! Pure-Rust SAML 2.0 Service Provider (v0.5.1).
//!
//! This module implements the SP half of a SAML Web-Browser-SSO profile:
//!
//! * [`metadata`] — parse the IdP's `EntityDescriptor` (SSO URLs + signing
//! certificates) and build *our* SP metadata for the IdP admin to import.
//! * [`authn_request`] — build an `AuthnRequest` and encode it for the
//! HTTP-Redirect binding.
//! * [`response`] — decode a `SAMLResponse`, **verify its XML signature**
//! against the IdP's pinned certificate (via the pure-Rust `bergshamra`
//! crate — no OpenSSL/libxml2/xmlsec, so the static musl binary stays
//! C-free), then enforce the SP-side semantic checks (Status, Destination,
//! Audience, time bounds) that are where SAML SPs actually get attacked.
//!
//! Stateful checks (replay of assertion IDs, correlating `InResponseTo`
//! against requests *we* issued, gating IdP-initiated login) live in the
//! HTTP layer — [`response::consume`] is deliberately stateless and returns
//! the IDs the caller needs to perform them.
//!
//! Access model: any assertion the IdP authenticates and we cryptographically
//! verify yields an operator [`VerifiedPrincipal`]. OpenPXE is single-tier —
//! there is no per-user role table — and the local admin account remains a
//! guaranteed fallback owner regardless of SSO state.
pub mod authn_request;
pub mod metadata;
pub mod response;
pub use authn_request::AuthnRequest;
pub use metadata::IdpMetadata;
pub use response::{VerifiedPrincipal, VerifiedResponse};
use thiserror::Error;
/// Default clock-skew tolerance applied to assertion time bounds. SAML IdPs
/// and SPs rarely have perfectly synced clocks; 60s matches common practice
/// (Shibboleth/FleetDM defaults are in this ballpark).
pub const DEFAULT_CLOCK_SKEW_SECS: i64 = 60;
/// Runtime SP parameters, derived from [`crate::SsoConfig`] + the advertised
/// public base URL by the HTTP layer.
#[derive(Debug, Clone)]
pub struct SpParams {
/// Our SP Entity ID (the `<Issuer>` we send and the `Audience` we require
/// in responses). Defaults to the public base URL when the operator left
/// the Entity ID field blank.
pub entity_id: String,
/// The Assertion Consumer Service URL the IdP POSTs the response to —
/// `<public_base_url>/api/sso/acs`.
pub acs_url: String,
}
/// Everything that can go wrong consuming a SAML response. Kept coarse on
/// purpose: the HTTP layer logs the detail and shows the operator a generic
/// "SSO sign-in failed" — we never leak which specific check tripped to the
/// browser, since that aids an attacker probing the SP.
#[derive(Debug, Error)]
pub enum SamlError {
#[error("SAML XML parse error: {0}")]
Xml(String),
#[error("IdP metadata is missing a required element: {0}")]
Metadata(String),
#[error("no usable IdP signing certificate in metadata")]
NoSigningCert,
#[error("signature verification failed: {0}")]
Signature(String),
#[error("the signature does not cover the assertion we read")]
SignatureScope,
#[error("SAML response status was not Success: {0}")]
Status(String),
#[error("response is missing a required element: {0}")]
MissingElement(String),
#[error("encrypted assertions are not supported in this release")]
EncryptedAssertionUnsupported,
#[error("expected exactly one assertion, found {0}")]
AssertionCount(usize),
#[error("issuer mismatch: response was not issued by the configured IdP")]
IssuerMismatch,
#[error("audience mismatch: assertion is not addressed to this service provider")]
AudienceMismatch,
#[error("response destination does not match our ACS URL")]
DestinationMismatch,
#[error("assertion is expired or not yet valid")]
TimeBounds,
#[error("invalid SAML timestamp: {0}")]
Timestamp(String),
#[error("base64 decode failed: {0}")]
Base64(String),
}
#[cfg(test)]
mod tests;
-294
View File
@@ -1,294 +0,0 @@
//! SAMLResponse consumption: signature verification + SP-side validation.
//!
//! [`consume`] is intentionally **stateless** — it verifies the XML signature
//! against the IdP's pinned certificate(s) and enforces every check that can
//! be made from the response alone (Status, Destination, Issuer, Audience,
//! time bounds, signature scope). It then returns the `assertion_id` and
//! `in_response_to` so the HTTP layer can perform the *stateful* checks it
//! owns: replay rejection, correlating the request we issued, and gating
//! IdP-initiated login.
use roxmltree::{Document, Node};
use time::format_description::well_known::Rfc3339;
use time::{Duration, OffsetDateTime};
use super::metadata::IdpMetadata;
use super::{SamlError, SpParams};
const STATUS_SUCCESS: &str = "urn:oasis:names:tc:SAML:2.0:status:Success";
/// The verified subject of a SAML assertion. OpenPXE is single-tier, so this
/// is all an operator session needs.
#[derive(Debug, Clone)]
pub struct VerifiedPrincipal {
/// The `<NameID>` value (an email, per our requested NameID format).
pub name_id: String,
/// Email used as the session identity. Equals `name_id` for the
/// emailAddress NameID format.
pub email: String,
/// Human-readable display name, if the IdP sent one as an attribute.
pub display_name: Option<String>,
}
/// Result of a successful [`consume`]. The IDs/expiry feed the HTTP layer's
/// stateful checks.
#[derive(Debug, Clone)]
pub struct VerifiedResponse {
pub principal: VerifiedPrincipal,
/// `InResponseTo` from the response, if present. `None` = unsolicited
/// (IdP-initiated) — the HTTP layer only accepts that when the operator
/// enabled it.
pub in_response_to: Option<String>,
/// The assertion's `ID` — used by the caller as the replay-guard key.
pub assertion_id: String,
/// The assertion's expiry (`Conditions/@NotOnOrAfter`) — the replay
/// guard can drop the consumed ID after this instant.
pub assertion_expiry: OffsetDateTime,
/// `AuthnStatement/@SessionIndex`, if present (useful for future SLO).
pub session_index: Option<String>,
}
/// Verify and validate a decoded `SAMLResponse` XML document.
pub fn consume(
xml: &str,
sp: &SpParams,
idp: &IdpMetadata,
now: OffsetDateTime,
clock_skew: Duration,
) -> Result<VerifiedResponse, SamlError> {
// 1. Cryptographically verify the signature against the pinned IdP cert(s).
// `trusted_keys_only` ignores any cert embedded in the document's
// KeyInfo, so an attacker can't substitute their own key.
let verified_uris = verify_signature(xml, &idp.signing_certs_der)?;
// 2. Parse for semantic validation.
let doc = Document::parse(xml).map_err(|e| SamlError::Xml(e.to_string()))?;
let root = doc.root_element();
if root.tag_name().name() != "Response" {
return Err(SamlError::MissingElement("Response".into()));
}
let response_id = root.attribute("ID").map(str::to_owned);
let in_response_to = root.attribute("InResponseTo").map(str::to_owned);
// 3. Status must be Success.
let status_value = root
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "StatusCode")
.and_then(|sc| sc.attribute("Value"))
.unwrap_or("");
if status_value != STATUS_SUCCESS && !status_value.ends_with(":Success") {
return Err(SamlError::Status(status_value.to_owned()));
}
// 4. Destination (if the IdP set one) must be our ACS.
if let Some(dest) = root.attribute("Destination") {
if !urls_equal(dest, &sp.acs_url) {
return Err(SamlError::DestinationMismatch);
}
}
// 5. Exactly one (unencrypted) Assertion.
if root
.descendants()
.any(|n| n.is_element() && n.tag_name().name() == "EncryptedAssertion")
{
return Err(SamlError::EncryptedAssertionUnsupported);
}
let assertions: Vec<Node<'_, '_>> = root
.children()
.filter(|c| c.is_element() && c.tag_name().name() == "Assertion")
.collect();
if assertions.len() != 1 {
return Err(SamlError::AssertionCount(assertions.len()));
}
let assertion = assertions[0];
let assertion_id = assertion
.attribute("ID")
.map(str::to_owned)
.ok_or_else(|| SamlError::MissingElement("Assertion/@ID".into()))?;
// 6. The signature must actually cover the assertion we're about to trust:
// either the assertion itself, the enclosing response, or the whole
// document. (bergshamra's strict_verification already constrains where
// the signed element may sit; this ties it to *our* assertion.)
let covers_assertion = verified_uris.iter().any(|u| {
u.is_empty()
|| u == &format!("#{assertion_id}")
|| response_id
.as_ref()
.is_some_and(|rid| u == &format!("#{rid}"))
});
if !covers_assertion {
return Err(SamlError::SignatureScope);
}
// 7. Issuer must be the configured IdP.
let issuer = first_child(assertion, "Issuer")
.map(text_of)
.unwrap_or_default();
if !idp.entity_id.is_empty() && issuer != idp.entity_id {
return Err(SamlError::IssuerMismatch);
}
// 8. Subject → NameID + SubjectConfirmationData time/recipient checks.
let subject = first_child(assertion, "Subject")
.ok_or_else(|| SamlError::MissingElement("Subject".into()))?;
let name_id = first_child(subject, "NameID")
.map(text_of)
.filter(|s| !s.is_empty())
.ok_or_else(|| SamlError::MissingElement("NameID".into()))?;
if let Some(scd) = subject
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "SubjectConfirmationData")
{
if let Some(recipient) = scd.attribute("Recipient") {
if !urls_equal(recipient, &sp.acs_url) {
return Err(SamlError::DestinationMismatch);
}
}
if let Some(noa) = scd.attribute("NotOnOrAfter") {
let noa = parse_instant(noa)?;
if now >= noa + clock_skew {
return Err(SamlError::TimeBounds);
}
}
}
// 9. Conditions: time window + audience.
let conditions = first_child(assertion, "Conditions");
if let Some(cond) = conditions {
if let Some(nb) = cond.attribute("NotBefore") {
let nb = parse_instant(nb)?;
if now < nb - clock_skew {
return Err(SamlError::TimeBounds);
}
}
}
let assertion_expiry = conditions
.and_then(|c| c.attribute("NotOnOrAfter"))
.map(parse_instant)
.transpose()?
.ok_or_else(|| SamlError::MissingElement("Conditions/@NotOnOrAfter".into()))?;
if now >= assertion_expiry + clock_skew {
return Err(SamlError::TimeBounds);
}
let audience_ok = conditions.is_some_and(|c| {
c.descendants()
.filter(|n| n.is_element() && n.tag_name().name() == "Audience")
.any(|a| text_of(a) == sp.entity_id)
});
if !audience_ok {
return Err(SamlError::AudienceMismatch);
}
// 10. Optional: SessionIndex + display-name attribute.
let session_index = assertion
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "AuthnStatement")
.and_then(|a| a.attribute("SessionIndex"))
.map(str::to_owned);
let display_name = extract_display_name(assertion);
Ok(VerifiedResponse {
principal: VerifiedPrincipal {
email: name_id.clone(),
name_id,
display_name,
},
in_response_to,
assertion_id,
assertion_expiry,
session_index,
})
}
/// Verify the document's XML-DSig against each pinned IdP cert in turn
/// (handles key rotation), returning the verified `<Reference>` URIs.
fn verify_signature(xml: &str, certs_der: &[Vec<u8>]) -> Result<Vec<String>, SamlError> {
let mut last_err = String::from("no signing certificate matched");
for der in certs_der {
let key = match bergshamra::keys::loader::load_x509_cert_der(der) {
Ok(k) => k,
Err(e) => {
last_err = e.to_string();
continue;
}
};
let mut km = bergshamra::keys::KeysManager::new();
km.add_key(key);
// trusted_keys_only: only ever trust the pinned IdP key, never an
// inline KeyInfo cert. strict_verification: XSW positional defense.
let ctx = bergshamra::DsigContext::new(km)
.with_trusted_keys_only(true)
.with_strict_verification(true);
match bergshamra::verify(&ctx, xml) {
Ok(bergshamra::VerifyResult::Valid { references, .. }) => {
return Ok(references.into_iter().map(|r| r.uri).collect());
}
Ok(bergshamra::VerifyResult::Invalid { reason }) => last_err = reason,
Err(e) => last_err = e.to_string(),
}
}
Err(SamlError::Signature(last_err))
}
/// Pull a display name from the assertion's attribute statement, trying the
/// common attribute names IdPs use (FleetDM checks the same set).
fn extract_display_name(assertion: Node<'_, '_>) -> Option<String> {
const WANTED: &[&str] = &[
"name",
"displayname",
"cn",
"urn:oid:2.5.4.3",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
];
for attr in assertion
.descendants()
.filter(|n| n.is_element() && n.tag_name().name() == "Attribute")
{
let key = attr
.attribute("Name")
.or_else(|| attr.attribute("FriendlyName"))
.unwrap_or("")
.to_ascii_lowercase();
if WANTED.contains(&key.as_str()) {
if let Some(val) = attr
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "AttributeValue")
{
let v = text_of(val);
if !v.is_empty() {
return Some(v);
}
}
}
}
None
}
fn first_child<'a, 'i>(n: Node<'a, 'i>, local: &str) -> Option<Node<'a, 'i>> {
n.children()
.find(|c| c.is_element() && c.tag_name().name() == local)
}
fn text_of(n: Node<'_, '_>) -> String {
n.children()
.filter(Node::is_text)
.filter_map(|c| c.text())
.collect::<String>()
.trim()
.to_owned()
}
/// Parse an `xs:dateTime` (e.g. `2026-05-31T12:00:00.000Z`).
fn parse_instant(s: &str) -> Result<OffsetDateTime, SamlError> {
OffsetDateTime::parse(s.trim(), &Rfc3339).map_err(|e| SamlError::Timestamp(format!("{s}: {e}")))
}
/// Compare two URLs for SAML endpoint-matching purposes: exact, or differing
/// only by a single trailing slash.
fn urls_equal(a: &str, b: &str) -> bool {
a == b || a.trim_end_matches('/') == b.trim_end_matches('/')
}
-287
View File
@@ -1,287 +0,0 @@
//! End-to-end SAML SP tests.
//!
//! We mint a throwaway self-signed cert/key with `rcgen`, sign a SAML Response
//! template with `bergshamra::sign` (the same engine that verifies it), and
//! drive [`response::consume`] through the accept path and every reject path.
//! This proves both the signature wiring and the SP-semantic checks.
use time::format_description::well_known::Rfc3339;
use time::{Duration, OffsetDateTime};
use super::metadata::IdpMetadata;
use super::{response, SamlError, SpParams};
const SP_ENTITY: &str = "https://pxe.example.com";
const ACS: &str = "https://pxe.example.com/api/sso/acs";
const IDP_ENTITY: &str = "https://idp.example.com/realms/fleet";
const EMAIL: &str = "[email protected]";
struct TestIdp {
cert_der: Vec<u8>,
key_pem: String,
}
fn test_idp() -> TestIdp {
let ck = rcgen::generate_simple_self_signed(vec!["idp.example.com".to_string()]).unwrap();
TestIdp {
cert_der: ck.cert.der().as_ref().to_vec(),
key_pem: ck.key_pair.serialize_pem(),
}
}
fn fmt(t: OffsetDateTime) -> String {
t.replace_nanosecond(0).unwrap().format(&Rfc3339).unwrap()
}
/// Knobs for building a response template — defaults are a valid response.
struct Resp {
issuer: String,
audience: String,
status: String,
not_before: OffsetDateTime,
not_on_or_after: OffsetDateTime,
in_response_to: Option<String>,
recipient: String,
}
impl Default for Resp {
fn default() -> Self {
let now = OffsetDateTime::now_utc();
Self {
issuer: IDP_ENTITY.into(),
audience: SP_ENTITY.into(),
status: "urn:oasis:names:tc:SAML:2.0:status:Success".into(),
not_before: now - Duration::minutes(5),
not_on_or_after: now + Duration::hours(1),
in_response_to: Some("_req-abc".into()),
recipient: ACS.into(),
}
}
}
impl Resp {
/// The unsigned template (a `<ds:Signature>` with empty values).
fn template(&self) -> String {
let now = fmt(OffsetDateTime::now_utc());
let irt = self
.in_response_to
.as_ref()
.map(|v| format!(r#" InResponseTo="{v}""#))
.unwrap_or_default();
format!(
r##"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_resp1" Version="2.0" IssueInstant="{now}" Destination="{ACS}"{irt}>
<saml:Issuer>{issuer}</saml:Issuer>
<samlp:Status><samlp:StatusCode Value="{status}"/></samlp:Status>
<saml:Assertion ID="_assertion1" Version="2.0" IssueInstant="{now}">
<saml:Issuer>{issuer}</saml:Issuer>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256"/>
<ds:Reference URI="#_assertion1">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue></ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue></ds:SignatureValue>
</ds:Signature>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">{EMAIL}</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData Recipient="{recipient}" NotOnOrAfter="{noa}"{irt}/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="{nb}" NotOnOrAfter="{noa}">
<saml:AudienceRestriction><saml:Audience>{audience}</saml:Audience></saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="{now}" SessionIndex="sess-123">
<saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef></saml:AuthnContext>
</saml:AuthnStatement>
<saml:AttributeStatement>
<saml:Attribute Name="displayName"><saml:AttributeValue>Miles Ward</saml:AttributeValue></saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
</samlp:Response>"##,
issuer = self.issuer,
status = self.status,
audience = self.audience,
recipient = self.recipient,
nb = fmt(self.not_before),
noa = fmt(self.not_on_or_after),
)
}
}
fn sign(template: &str, key_pem: &str) -> String {
let key = bergshamra::keys::loader::load_pem_auto(key_pem.as_bytes(), None)
.expect("load test signing key");
let mut km = bergshamra::keys::KeysManager::new();
km.add_key(key);
let ctx = bergshamra::DsigContext::new(km);
bergshamra::sign(&ctx, template).expect("sign test response")
}
fn sp() -> SpParams {
SpParams {
entity_id: SP_ENTITY.into(),
acs_url: ACS.into(),
}
}
fn idp(cert_der: Vec<u8>) -> IdpMetadata {
IdpMetadata {
entity_id: IDP_ENTITY.into(),
sso_redirect_url: None,
sso_post_url: None,
signing_certs_der: vec![cert_der],
}
}
fn consume(xml: &str, cert_der: Vec<u8>) -> Result<response::VerifiedResponse, SamlError> {
response::consume(
xml,
&sp(),
&idp(cert_der),
OffsetDateTime::now_utc(),
Duration::seconds(60),
)
}
#[test]
fn good_response_yields_principal() {
let t = test_idp();
let signed = sign(&Resp::default().template(), &t.key_pem);
let out = consume(&signed, t.cert_der).expect("valid response should verify");
assert_eq!(out.principal.email, EMAIL);
assert_eq!(out.principal.name_id, EMAIL);
assert_eq!(out.principal.display_name.as_deref(), Some("Miles Ward"));
assert_eq!(out.in_response_to.as_deref(), Some("_req-abc"));
assert_eq!(out.assertion_id, "_assertion1");
assert_eq!(out.session_index.as_deref(), Some("sess-123"));
}
#[test]
fn tampered_assertion_is_rejected() {
let t = test_idp();
let signed = sign(&Resp::default().template(), &t.key_pem);
// Flip the subject email after signing — breaks the digest.
let tampered = signed.replace(EMAIL, "[email protected]");
assert_ne!(signed, tampered);
assert!(matches!(
consume(&tampered, t.cert_der),
Err(SamlError::Signature(_) | SamlError::SignatureScope)
));
}
#[test]
fn unsigned_response_is_rejected() {
let t = test_idp();
// Feed the *unsigned* template (empty SignatureValue) straight in.
let unsigned = Resp::default().template();
assert!(matches!(
consume(&unsigned, t.cert_der),
Err(SamlError::Signature(_))
));
}
#[test]
fn wrong_signing_key_is_rejected() {
let signer = test_idp();
let other = test_idp(); // different keypair pinned as the "IdP" cert
let signed = sign(&Resp::default().template(), &signer.key_pem);
assert!(matches!(
consume(&signed, other.cert_der),
Err(SamlError::Signature(_))
));
}
#[test]
fn wrong_audience_is_rejected() {
let t = test_idp();
let r = Resp {
audience: "https://someone-else.example".into(),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::AudienceMismatch)
));
}
#[test]
fn expired_assertion_is_rejected() {
let t = test_idp();
let now = OffsetDateTime::now_utc();
let r = Resp {
not_before: now - Duration::hours(2),
not_on_or_after: now - Duration::hours(1),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::TimeBounds)
));
}
#[test]
fn future_assertion_is_rejected() {
let t = test_idp();
let now = OffsetDateTime::now_utc();
let r = Resp {
not_before: now + Duration::hours(1),
not_on_or_after: now + Duration::hours(2),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::TimeBounds)
));
}
#[test]
fn wrong_issuer_is_rejected() {
let t = test_idp();
let r = Resp {
issuer: "https://evil-idp.example".into(),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::IssuerMismatch)
));
}
#[test]
fn non_success_status_is_rejected() {
let t = test_idp();
let r = Resp {
status: "urn:oasis:names:tc:SAML:2.0:status:Requester".into(),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::Status(_))
));
}
#[test]
fn idp_initiated_has_no_in_response_to() {
// No InResponseTo => the HTTP layer must gate it behind allow_idp_initiated.
let t = test_idp();
let r = Resp {
in_response_to: None,
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
let out = consume(&signed, t.cert_der).expect("unsolicited but otherwise valid");
assert!(out.in_response_to.is_none());
}
+8 -14
View File
@@ -48,9 +48,9 @@ pub struct Settings {
pub default_local_hdd: bool, pub default_local_hdd: bool,
/// When a client hits the Queued Deployment item, how long (seconds) to /// When a client hits the Queued Deployment item, how long (seconds) to
/// hold it in queue before giving up and falling back to the menu. /// hold it at the gate before giving up and falling back to the menu.
/// 0 = forever. /// 0 = forever.
pub queue_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 — OpenPXE does not run a DNS server, but /// informational today — OpenPXE does not run a DNS server, but
@@ -67,8 +67,11 @@ pub enum TimeoutAction {
/// Chain the "Boot from Local HDD" entry. /// Chain the "Boot from Local HDD" entry.
LocalHdd, LocalHdd,
/// Put the client into the deployment queue, waiting for operator /// Put the client into the deployment queue, waiting for operator
/// assignment. /// 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]
#[serde(alias = "gated_deployment")]
QueuedDeployment, QueuedDeployment,
} }
@@ -81,7 +84,7 @@ impl Default for Settings {
smb_host_override: String::new(), smb_host_override: String::new(),
extra_kernel_args: String::new(), extra_kernel_args: String::new(),
default_local_hdd: true, default_local_hdd: true,
queue_wait_max_secs: 0, gate_wait_max_secs: 0,
dns_server: String::new(), dns_server: String::new(),
} }
} }
@@ -112,10 +115,7 @@ impl SettingsStore {
}, },
Err(_) => Settings::default(), Err(_) => Settings::default(),
}; };
Arc::new(Self { Arc::new(Self { path, inner: RwLock::new(initial) })
path,
inner: RwLock::new(initial),
})
} }
#[must_use] #[must_use]
@@ -181,12 +181,6 @@ mod tests {
assert!(s.windows_enabled); assert!(s.windows_enabled);
} }
#[test]
fn settings_serialize_queue_naming() {
let text = serde_json::to_string(&Settings::default()).unwrap();
assert!(text.contains("queue_wait_max_secs"));
}
#[test] #[test]
fn corrupt_file_falls_back_to_default() { fn corrupt_file_falls_back_to_default() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
-420
View File
@@ -1,420 +0,0 @@
//! SAML SSO configuration — FleetDM-shaped.
//!
//! The operator pastes their IdP's metadata XML (or its URL) and a
//! human-readable label. As of v0.5.1 the SAML login flow is wired
//! end-to-end (see [`crate::saml`]): SP-initiated AuthnRequest, the ACS
//! endpoint, pure-Rust signature verification, and operator-session
//! minting. This module owns only the persisted *configuration*.
//!
//! Shape borrowed from <https://github.com/fleetdm/fleet>'s app-config
//! SSO block, minus the user-RBAC fields (OpenPXE is single-tier: any
//! IdP-authenticated user the SP cryptographically verifies gets an
//! operator session; there is no per-user role table). Entity ID is
//! exposed (FleetDM-style) but defaults to the advertised public base
//! URL when blank, which is what most IdPs expect anyway.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use crate::{Error, Result};
/// The configurable surface. `metadata` and `metadata_url` are mutually
/// exclusive at apply time (one or the other identifies the IdP); the
/// store keeps both fields so an operator can switch between them
/// without losing the inactive one.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SsoConfig {
/// Master switch — when false, all SSO machinery (planned for a
/// later release) is skipped regardless of the rest of the fields.
#[serde(default)]
pub enabled: bool,
/// Display name shown on the WebUI's login screen as the "Sign in
/// with X" button label. Empty/whitespace falls back to "SSO".
#[serde(default)]
pub idp_name: String,
/// Optional HTTPS URL pointing at the IdP's brand logo. Rendered
/// next to `idp_name` on the WebUI's login screen (FleetDM-style).
/// Length-capped at [`MAX_URL_LEN`]; empty is fine.
#[serde(default)]
pub idp_logo_url: String,
/// Raw SAML metadata XML pasted by the operator. Mutually exclusive
/// with `metadata_url`; if both are set, the URL wins at apply time
/// (operators typically forget about a stale XML paste).
#[serde(default)]
pub metadata: String,
/// HTTPS URL where the IdP serves its metadata. Loaded lazily by the
/// future SAML flow; not validated here beyond a basic length cap.
#[serde(default)]
pub metadata_url: String,
/// SP Entity ID advertised to the IdP — mirrors FleetDM's "Entity ID".
/// Must exactly match the SP/Relying-Party entry configured on the IdP.
/// Empty falls back to the advertised public base URL at runtime, which
/// is what most IdPs expect. Length-capped at [`MAX_URL_LEN`].
#[serde(default)]
pub entity_id: String,
/// Allow IdP-initiated login — an unsolicited `<Response>` POSTed to the
/// ACS with no `InResponseTo`. Mirrors FleetDM's "Allow SSO login
/// initiated by identity provider". Default off; SP-initiated (the
/// "Sign in with X" button) is always allowed regardless.
#[serde(default)]
pub allow_idp_initiated: bool,
}
impl SsoConfig {
/// Returns `true` only when the config is *usable* — enabled, and
/// at least one of metadata/metadata_url is present. The future
/// login flow will key off this; for v0.4.5 the WebUI uses it to
/// surface a yellow "configured but not live yet" hint.
#[must_use]
pub fn is_usable(&self) -> bool {
self.enabled && (!self.metadata.trim().is_empty() || !self.metadata_url.trim().is_empty())
}
}
/// The minimal, non-sensitive slice of the SSO config that the **pre-auth**
/// login screen needs to render the "Sign in with …" button. Carries only
/// the display affordances — never the metadata XML/URL or entity ID, which
/// stay behind the auth-gated `/api/sso`. Served as part of the public
/// `/api/me` so the button renders reliably whether or not anyone is signed
/// in (v0.5.9: fixes the button vanishing because `/api/sso` 401s pre-auth).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SsoLoginInfo {
/// True only when SSO is *usable* (enabled AND a metadata source is
/// present) — i.e. clicking the button will actually reach an IdP.
pub enabled: bool,
/// Button label, e.g. "STC AD". Empty falls back to "SSO" in the UI.
pub idp_name: String,
/// Optional IdP logo rendered on the button. Empty = no image.
pub idp_logo_url: String,
}
/// In-memory + on-disk SSO settings registry.
#[derive(Debug, Clone)]
pub struct SsoStore {
path: Arc<PathBuf>,
inner: Arc<RwLock<SsoConfig>>,
}
impl SsoStore {
/// Load from `<work_dir>/sso.json`, or start with the default empty
/// (`enabled = false`) config. A corrupt file falls back to default
/// rather than blocking startup.
#[must_use]
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("sso.json");
let cfg = match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str::<SsoConfig>(&text) {
Ok(parsed) => parsed,
Err(e) => {
tracing::warn!(
target: "openpxe::sso",
"sso.json present but unreadable ({e}); starting with default config"
);
SsoConfig::default()
}
},
Err(_) => SsoConfig::default(),
};
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(cfg)),
}
}
#[must_use]
pub fn snapshot(&self) -> SsoConfig {
self.inner.read().clone()
}
/// Public, non-sensitive descriptor for the login screen. Safe to
/// expose pre-auth — it's exactly what the "Sign in with …" button
/// keys off, with no metadata/entity-ID leakage. v0.5.9.
#[must_use]
pub fn login_info(&self) -> SsoLoginInfo {
let cfg = self.inner.read();
SsoLoginInfo {
enabled: cfg.is_usable(),
idp_name: cfg.idp_name.clone(),
idp_logo_url: cfg.idp_logo_url.clone(),
}
}
/// Replace the whole config in one shot. Light validation: metadata
/// XML and URL are length-capped so an operator can't OOM us by
/// pasting a 10 GiB blob; the IdP UI tab clamps the input visually,
/// but the server enforces a hard ceiling regardless.
pub fn replace(&self, mut cfg: SsoConfig) -> Result<SsoConfig> {
cfg.idp_name = cfg.idp_name.trim().to_string();
cfg.idp_logo_url = cfg.idp_logo_url.trim().to_string();
cfg.metadata = cfg.metadata.trim().to_string();
cfg.metadata_url = cfg.metadata_url.trim().to_string();
cfg.entity_id = cfg.entity_id.trim().to_string();
if cfg.entity_id.len() > MAX_URL_LEN {
return Err(Error::Invalid(format!(
"entity_id exceeds {MAX_URL_LEN}-char cap"
)));
}
if cfg.metadata.len() > MAX_METADATA_BYTES {
return Err(Error::Invalid(format!(
"metadata XML exceeds {MAX_METADATA_BYTES}-byte cap"
)));
}
if cfg.metadata_url.len() > MAX_URL_LEN {
return Err(Error::Invalid(format!(
"metadata_url exceeds {MAX_URL_LEN}-char cap"
)));
}
if cfg.idp_logo_url.len() > MAX_URL_LEN {
return Err(Error::Invalid(format!(
"idp_logo_url exceeds {MAX_URL_LEN}-char cap"
)));
}
if !cfg.metadata_url.is_empty()
&& !cfg.metadata_url.starts_with("http://")
&& !cfg.metadata_url.starts_with("https://")
{
return Err(Error::Invalid(
"metadata_url must start with http:// or https://".into(),
));
}
if !cfg.idp_logo_url.is_empty()
&& !cfg.idp_logo_url.starts_with("http://")
&& !cfg.idp_logo_url.starts_with("https://")
{
return Err(Error::Invalid(
"idp_logo_url must start with http:// or https://".into(),
));
}
// If they're trying to *enable* the integration but haven't
// supplied either source, reject — saves a "configured but
// unusable" surprise later.
if cfg.enabled && cfg.metadata.is_empty() && cfg.metadata_url.is_empty() {
return Err(Error::Invalid(
"enable SSO requires either metadata XML or a metadata URL".into(),
));
}
{
let mut g = self.inner.write();
*g = cfg.clone();
}
self.persist();
tracing::info!(
target: "openpxe::sso",
enabled = cfg.enabled,
idp = %cfg.idp_name,
has_xml = !cfg.metadata.is_empty(),
has_url = !cfg.metadata_url.is_empty(),
"sso configuration updated"
);
Ok(cfg)
}
fn persist(&self) {
let snap = self.inner.read().clone();
let body = match serde_json::to_vec_pretty(&snap) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::sso", "serialize sso.json: {e}");
return;
}
};
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = self.path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::sso", "write sso.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::warn!(target: "openpxe::sso", "rename sso.json: {e}");
}
}
}
/// Saturation caps. The numbers are generous for any real IdP metadata
/// document — Okta's largest is ~50 KB, Azure AD's ~30 KB.
const MAX_METADATA_BYTES: usize = 1024 * 1024;
const MAX_URL_LEN: usize = 2048;
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn default_is_disabled_and_empty() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let cfg = s.snapshot();
assert!(!cfg.enabled);
assert!(cfg.metadata.is_empty());
assert!(cfg.metadata_url.is_empty());
assert!(!cfg.is_usable());
}
#[test]
fn replace_metadata_url_round_trip_via_disk() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
s.replace(SsoConfig {
enabled: true,
idp_name: "Okta".into(),
metadata: String::new(),
metadata_url: "https://idp.example.com/metadata".into(),
idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
})
.unwrap();
drop(s);
let s2 = SsoStore::load_or_default(dir.path());
let cfg = s2.snapshot();
assert!(cfg.enabled);
assert!(cfg.is_usable());
assert_eq!(cfg.idp_name, "Okta");
assert_eq!(cfg.metadata_url, "https://idp.example.com/metadata");
}
#[test]
fn replace_xml_paste_is_accepted() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let xml = r#"<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata">test</EntityDescriptor>"#;
s.replace(SsoConfig {
enabled: true,
idp_name: "Test IdP".into(),
metadata: xml.into(),
metadata_url: String::new(),
idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
})
.unwrap();
assert!(s.snapshot().is_usable());
}
#[test]
fn enable_without_source_is_rejected() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let r = s.replace(SsoConfig {
enabled: true,
idp_name: "Okta".into(),
metadata: String::new(),
metadata_url: String::new(),
idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
});
assert!(matches!(r, Err(Error::Invalid(_))));
// …and a disabled blank config is fine.
s.replace(SsoConfig::default()).unwrap();
}
#[test]
fn metadata_url_must_be_http_scheme() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let r = s.replace(SsoConfig {
enabled: false,
idp_name: String::new(),
metadata: String::new(),
metadata_url: "ftp://idp.example.com/metadata".into(),
idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
});
assert!(matches!(r, Err(Error::Invalid(_))));
}
#[test]
fn idp_logo_url_must_be_http_scheme() {
// v0.4.6: SSO settings learned an idp_logo_url so the login
// screen can render the FleetDM-style "Sign in with <IdP-logo>"
// affordance. Same scheme rule as metadata_url.
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let r = s.replace(SsoConfig {
enabled: false,
idp_name: "Okta".into(),
metadata: String::new(),
metadata_url: String::new(),
idp_logo_url: "data:image/png;base64,...".into(),
entity_id: String::new(),
allow_idp_initiated: false,
});
assert!(matches!(r, Err(Error::Invalid(_))));
// Real HTTPS URL is fine.
s.replace(SsoConfig {
enabled: false,
idp_name: "Okta".into(),
metadata: String::new(),
metadata_url: String::new(),
idp_logo_url: "https://idp.example.com/logo.png".into(),
entity_id: String::new(),
allow_idp_initiated: false,
})
.unwrap();
assert_eq!(
s.snapshot().idp_logo_url,
"https://idp.example.com/logo.png"
);
}
#[test]
fn entity_id_and_idp_initiated_round_trip() {
// v0.5.1: SP Entity ID + IdP-initiated toggle persist across reload.
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
s.replace(SsoConfig {
enabled: true,
idp_name: "Keycloak".into(),
metadata: String::new(),
metadata_url: "https://idp.example.com/metadata".into(),
idp_logo_url: String::new(),
entity_id: "https://pxe.example.com".into(),
allow_idp_initiated: true,
})
.unwrap();
drop(s);
let cfg = SsoStore::load_or_default(dir.path()).snapshot();
assert_eq!(cfg.entity_id, "https://pxe.example.com");
assert!(cfg.allow_idp_initiated);
}
#[test]
fn entity_id_cap_enforced() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let r = s.replace(SsoConfig {
enabled: false,
idp_name: String::new(),
metadata: String::new(),
metadata_url: String::new(),
idp_logo_url: String::new(),
entity_id: "x".repeat(MAX_URL_LEN + 1),
allow_idp_initiated: false,
});
assert!(matches!(r, Err(Error::Invalid(_))));
}
#[test]
fn metadata_size_cap_enforced() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let oversize = "a".repeat(MAX_METADATA_BYTES + 1);
let r = s.replace(SsoConfig {
enabled: false,
idp_name: String::new(),
metadata: oversize,
metadata_url: String::new(),
idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
});
assert!(matches!(r, Err(Error::Invalid(_))));
}
}
-208
View File
@@ -1,208 +0,0 @@
//! Wake-on-LAN.
//!
//! v0.5.0: from the Hosts tab, an operator can wake a bound machine.
//! WoL is a "magic packet" — six `0xFF` bytes followed by the target
//! MAC repeated sixteen times (102 bytes total) — broadcast on the
//! local segment. The NIC's WoL logic matches the repeated MAC and
//! powers the board on.
//!
//! ## Why this is trivial and safe in our container
//!
//! - It's a single UDP datagram to a broadcast address. No privileged
//! *local* port is needed (we bind an ephemeral source port); the
//! destination port is conventionally 9 (discard) or 7 (echo), and
//! nothing actually listens there — the magic is in the payload, not
//! the port. So WoL works without any extra capability.
//! - We send to the limited broadcast `255.255.255.255` (stays on the
//! local link) and, when the caller knows the server's own subnet
//! broadcast, to that too — directed broadcast reaches the right VLAN
//! even when the host bridges multiple segments.
//!
//! ## Limits
//!
//! WoL only crosses L2. If the target is on a different subnet than the
//! OpenPXE host, the intervening router must be configured to forward
//! directed broadcasts (most aren't, by design). For the common case —
//! OpenPXE and its PXE clients on the same VLAN — the limited broadcast
//! is enough.
use crate::{Error, Result};
use std::net::{Ipv4Addr, SocketAddrV4, UdpSocket};
/// Conventional WoL destination port. 9 (discard) is the de-facto
/// default; the port is immaterial since the match is on the payload.
const WOL_PORT: u16 = 9;
/// Parse a MAC string in any common form (`aa:bb:cc:dd:ee:ff`,
/// `aa-bb-...`, `aabb.ccdd.eeff`, or bare hex) into six octets.
///
/// Returns `Error::Invalid` if it doesn't resolve to exactly six bytes.
pub fn parse_mac(mac: &str) -> Result<[u8; 6]> {
// Strip every non-hex-digit, then expect exactly 12 hex chars.
let hex: String = mac.chars().filter(char::is_ascii_hexdigit).collect();
if hex.len() != 12 {
return Err(Error::Invalid(format!(
"invalid MAC '{mac}': expected 6 octets (12 hex digits), got {}",
hex.len()
)));
}
let mut out = [0u8; 6];
for (i, byte) in out.iter_mut().enumerate() {
// Each octet is two hex chars; unwrap is safe — we validated
// the length and that every char is a hex digit above.
*byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16)
.map_err(|e| Error::Invalid(format!("invalid MAC '{mac}': {e}")))?;
}
Ok(out)
}
/// Build the 102-byte magic packet for `mac`.
#[must_use]
pub fn magic_packet(mac: [u8; 6]) -> [u8; 102] {
let mut pkt = [0u8; 102];
// 6 bytes of 0xFF.
for b in &mut pkt[..6] {
*b = 0xFF;
}
// MAC repeated 16 times.
for rep in 0..16 {
let start = 6 + rep * 6;
pkt[start..start + 6].copy_from_slice(&mac);
}
pkt
}
/// Send a Wake-on-LAN magic packet for `mac` to every address in
/// `broadcasts` (e.g. `255.255.255.255` plus the server's subnet
/// broadcast). Returns the number of broadcast addresses the packet was
/// successfully sent to; errors only if the MAC is malformed or the
/// socket can't be opened at all.
pub fn wake(mac: &str, broadcasts: &[Ipv4Addr]) -> Result<usize> {
let parsed = parse_mac(mac)?;
let packet = magic_packet(parsed);
// Always include the limited broadcast even if the caller didn't —
// it's the one that works with zero network configuration.
let mut targets: Vec<Ipv4Addr> = vec![Ipv4Addr::BROADCAST];
for b in broadcasts {
if !targets.contains(b) {
targets.push(*b);
}
}
let sent = send_magic(&packet, &targets, WOL_PORT)?;
tracing::info!(
target: "openpxe::wol",
mac = %mac, broadcasts = sent,
"Wake-on-LAN magic packet sent"
);
Ok(sent)
}
/// Open a broadcast-enabled UDP socket and send `packet` to every
/// `target:port`. Returns how many sends succeeded. Errors if the
/// socket can't be opened or if *no* target accepted the packet.
fn send_magic(packet: &[u8], targets: &[Ipv4Addr], port: u16) -> Result<usize> {
// Bind an ephemeral local UDP port on all interfaces. SO_BROADCAST
// must be enabled to send to a broadcast address.
let sock = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0))
.map_err(|e| Error::Invalid(format!("could not open WoL socket: {e}")))?;
sock.set_broadcast(true)
.map_err(|e| Error::Invalid(format!("could not enable broadcast: {e}")))?;
let mut sent = 0usize;
for &addr in targets {
match sock.send_to(packet, SocketAddrV4::new(addr, port)) {
Ok(_) => sent += 1,
Err(e) => {
tracing::warn!(
target: "openpxe::wol",
broadcast = %addr,
"WoL send failed: {e}"
);
}
}
}
if sent == 0 {
return Err(Error::Invalid(
"Wake-on-LAN: no broadcast address accepted the packet".into(),
));
}
Ok(sent)
}
/// Compute the IPv4 broadcast address for `ip`/`mask`, if both parse.
/// Used so the caller can include the server's own subnet broadcast
/// alongside the limited broadcast.
#[must_use]
pub fn subnet_broadcast(ip: Ipv4Addr, mask: Ipv4Addr) -> Ipv4Addr {
let ip = u32::from(ip);
let mask = u32::from(mask);
Ipv4Addr::from(ip | !mask)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_mac_accepts_common_forms() {
let want = [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff];
assert_eq!(parse_mac("aa:bb:cc:dd:ee:ff").unwrap(), want);
assert_eq!(parse_mac("AA-BB-CC-DD-EE-FF").unwrap(), want);
assert_eq!(parse_mac("aabb.ccdd.eeff").unwrap(), want);
assert_eq!(parse_mac("aabbccddeeff").unwrap(), want);
}
#[test]
fn parse_mac_rejects_bad_length() {
assert!(parse_mac("aa:bb:cc").is_err());
assert!(parse_mac("").is_err());
assert!(parse_mac("zz:bb:cc:dd:ee:ff").is_err()); // non-hex stripped → too short
}
#[test]
fn magic_packet_shape() {
let pkt = magic_packet([0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
assert_eq!(&pkt[..6], &[0xFF; 6]);
// First MAC repetition.
assert_eq!(&pkt[6..12], &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
// Last (16th) repetition ends the packet.
assert_eq!(&pkt[96..102], &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
}
#[test]
fn subnet_broadcast_computes() {
assert_eq!(
subnet_broadcast(
Ipv4Addr::new(192, 168, 1, 49),
Ipv4Addr::new(255, 255, 255, 0)
),
Ipv4Addr::new(192, 168, 1, 255)
);
assert_eq!(
subnet_broadcast(Ipv4Addr::new(10, 5, 3, 7), Ipv4Addr::new(255, 255, 0, 0)),
Ipv4Addr::new(10, 5, 255, 255)
);
}
#[test]
fn send_magic_delivers_intact_packet_over_loopback() {
// Deterministic round-trip that doesn't depend on the sandbox
// permitting a real L2 broadcast: bind a receiver on loopback
// and confirm send_magic transmits the exact 102-byte packet.
let rx = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).unwrap();
let port = rx.local_addr().unwrap().port();
rx.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.unwrap();
let packet = magic_packet([0x0a, 0x1b, 0x2c, 0x3d, 0x4e, 0x5f]);
let sent = send_magic(&packet, &[Ipv4Addr::LOCALHOST], port).unwrap();
assert_eq!(sent, 1);
let mut buf = [0u8; 128];
let n = rx.recv(&mut buf).unwrap();
assert_eq!(n, 102, "magic packet should be 102 bytes");
assert_eq!(&buf[..102], &packet[..]);
}
}
-6
View File
@@ -18,9 +18,3 @@ tracing.workspace = true
thiserror.workspace = true thiserror.workspace = true
anyhow.workspace = true anyhow.workspace = true
bytes.workspace = true bytes.workspace = true
parking_lot.workspace = true
# v0.7.1: learned driver modes persist to <work_dir>/driver_modes.json.
serde_json.workspace = true
[dev-dependencies]
tempfile = "3.12"
-494
View File
@@ -1,494 +0,0 @@
//! Automatic per-MAC boot-binary escalation (v0.6.1, extended v0.7.x).
//!
//! OpenPXE serves the firmware-net iPXE build (`snponly`/`undionly`) by
//! default — it's the most reliable choice for chainloading because the
//! firmware just proved its network works by downloading the NBP. Two
//! classes of machine can't run it:
//!
//! * a minority of NICs have a missing or buggy firmware UNDI/SNP stack —
//! they TFTP the binary fine but iPXE can't bring the link up;
//! * Secure-Boot firmware downloads it fine but refuses to *execute* an
//! unsigned image.
//!
//! Both look identical from here: the tell-tale second DHCP DISCOVER
//! carrying the `iPXE` user-class never arrives and the machine
//! re-PXE-boots. So a fresh firmware DISCOVER from a MAC whose previous
//! attempt was never confirmed climbs one rung:
//! `Firmware → Builtin → Shim` (the signed shim+GRUB chain). The decision
//! is sticky; there is no operator toggle; the default path is unchanged
//! so hardware that already boots never regresses.
//!
//! v0.7.1 — **learned modes persist**. Walking the ladder costs one or
//! two failed boot cycles, so a machine should pay it once *ever*, not
//! once per idle window or server restart. Two events pin a MAC's mode
//! to disk (`<work_dir>/driver_modes.json`):
//!
//! * a confirmed iPXE handoff at a non-default mode (Builtin proved to
//! work — also Shim, via the GRUB→iPXE same-boot chainload);
//! * reaching the terminal Shim rung (Secure-Boot machines never produce
//! an iPXE handoff from the signed menu, so escalation itself is the
//! best knowledge we'll ever have).
//!
//! Pinned entries are immune to the TTL and reload at startup. The
//! operator escape hatch is a rules-level driver-mode pin (which
//! overrides this table entirely) or deleting `driver_modes.json`.
use openpxe_core::DriverMode;
use parking_lot::Mutex;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
/// Multiple DISCOVERs within this window belong to the *same* boot (DHCP
/// retransmits, plus the :4011 PXE Boot Server query that follows the :67
/// DISCOVER). They must not be mistaken for a failed-and-retried boot.
const SAME_BOOT_DEBOUNCE: Duration = Duration::from_secs(8);
/// Forget an *unpinned* MAC's state after this long with no activity, so
/// a transient mid-walk state doesn't linger and the map stays bounded.
/// Pinned (learned) entries are exempt — that's their whole point.
const ENTRY_TTL: Duration = Duration::from_mins(30);
/// Hard cap on tracked MACs. Past this we evict the least-recently-seen
/// entry (unpinned first) — escalation is best-effort, never a
/// memory-growth vector.
const MAX_ENTRIES: usize = 4096;
/// How often (at most) the whole map is swept for expired entries.
/// Correctness doesn't depend on the sweep — a stale entry is also
/// detected inline when its MAC next appears — so the sweep only bounds
/// memory for MACs that never return, and amortizing it keeps the
/// per-packet path O(1) instead of O(map).
const PRUNE_INTERVAL: Duration = Duration::from_mins(1);
#[derive(Debug, Clone, Copy)]
struct Entry {
mode: DriverMode,
/// True once we've served `mode` and are waiting for the iPXE handoff to
/// confirm it worked. A *new* boot arriving while this is still true means
/// the previous attempt failed and we should escalate.
awaiting_confirm: bool,
/// Learned mode (v0.7.1): persisted to disk, exempt from the TTL.
pinned: bool,
last_seen: Instant,
}
#[derive(Debug)]
struct Inner {
map: HashMap<String, Entry>,
/// When the last full TTL sweep ran — see [`PRUNE_INTERVAL`].
last_prune: Instant,
}
impl Default for Inner {
fn default() -> Self {
Self {
map: HashMap::new(),
last_prune: Instant::now(),
}
}
}
/// Tracks per-MAC driver-mode escalation. Cheap to share via `Arc`.
#[derive(Debug, Default)]
pub struct DriverEscalation {
inner: Mutex<Inner>,
/// Persistence target for learned modes; `None` = ephemeral (tests).
path: Option<Arc<PathBuf>>,
}
impl DriverEscalation {
/// Ephemeral instance (no persistence) — used by tests.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Instance backed by `<work_dir>/driver_modes.json`. Learned modes
/// from previous runs are reloaded as pinned entries; a missing or
/// corrupt file starts empty (same crash-cache policy as every other
/// store — a bad file must never block PXE).
#[must_use]
pub fn load_or_default(work_dir: &Path) -> Self {
let path = work_dir.join("driver_modes.json");
let mut map = HashMap::new();
if let Ok(text) = std::fs::read_to_string(&path) {
match serde_json::from_str::<HashMap<String, DriverMode>>(&text) {
Ok(loaded) => {
let now = Instant::now();
for (mac, mode) in loaded {
// Firmware is the default — persisting it would be
// noise; tolerate it in the file but don't track it.
if mode == DriverMode::Firmware {
continue;
}
map.insert(
mac,
Entry {
mode,
awaiting_confirm: false,
pinned: true,
last_seen: now,
},
);
}
tracing::info!(
target: "openpxe::dhcp",
learned = map.len(),
"loaded learned driver modes"
);
}
Err(e) => {
tracing::warn!(
target: "openpxe::dhcp",
"driver_modes.json present but unreadable ({e}); starting empty"
);
}
}
}
Self {
inner: Mutex::new(Inner {
map,
last_prune: Instant::now(),
}),
path: Some(Arc::new(path)),
}
}
/// Decide the driver mode for a firmware (PXEClient/HTTPClient) boot from
/// `mac`. `primary` is true for the main DHCP DISCOVER (:67) and false for
/// the PXE Boot Server query (:4011); only the primary path drives
/// escalation, and only when it's clearly a *new* boot (outside the
/// same-boot debounce). The :4011 path just echoes the current mode.
pub fn mode_for_firmware_attempt(&self, mac: &str, primary: bool) -> DriverMode {
self.decide_at(mac, primary, Instant::now())
}
/// Record that `mac` completed the iPXE handoff (a DISCOVER carrying the
/// `iPXE` user-class). The mode we last served worked, so stop awaiting
/// confirmation, keep it sticky, and — for non-default modes — pin it to
/// disk so the machine never re-walks the ladder (v0.7.1).
pub fn mark_ipxe_success(&self, mac: &str) {
self.confirm_at(mac, Instant::now());
}
fn decide_at(&self, mac: &str, primary: bool, now: Instant) -> DriverMode {
let (mode, snapshot) = {
let mut g = self.inner.lock();
if now.duration_since(g.last_prune) >= PRUNE_INTERVAL {
g.map
.retain(|_, e| e.pinned || now.duration_since(e.last_seen) < ENTRY_TTL);
g.last_prune = now;
}
// Inline staleness check: an unpinned MAC whose entry outlived
// the TTL starts fresh even when the amortized sweep above
// hasn't caught it yet. Pinned entries never go stale.
if g.map
.get(mac)
.is_some_and(|e| !e.pinned && now.duration_since(e.last_seen) >= ENTRY_TTL)
{
g.map.remove(mac);
}
let mut newly_pinned = false;
let mode = match g.map.get_mut(mac) {
None => {
g.map.insert(
mac.to_owned(),
Entry {
mode: DriverMode::Firmware,
// Only the primary DISCOVER opens a confirmation window.
awaiting_confirm: primary,
pinned: false,
last_seen: now,
},
);
if g.map.len() > MAX_ENTRIES {
evict_oldest(&mut g.map);
}
DriverMode::Firmware
}
Some(entry) => {
let recent = now.duration_since(entry.last_seen) < SAME_BOOT_DEBOUNCE;
if primary && !recent {
// A genuinely new boot. If the previous attempt was
// never confirmed, the build we served failed → climb
// one rung: Firmware (firmware NIC stack) → Builtin
// (iPXE's own drivers) → Shim (signed shim+GRUB —
// covers Secure Boot firmware that downloads our
// unsigned iPXE but refuses to execute it). Shim is
// terminal and pins to disk: SB machines never emit
// an iPXE handoff from the signed menu, so reaching
// the rung *is* the durable knowledge.
if entry.awaiting_confirm {
entry.mode = match entry.mode {
DriverMode::Firmware => DriverMode::Builtin,
DriverMode::Builtin | DriverMode::Shim => DriverMode::Shim,
};
if entry.mode == DriverMode::Shim && !entry.pinned {
entry.pinned = true;
newly_pinned = true;
}
}
entry.awaiting_confirm = true;
}
entry.last_seen = now;
entry.mode
}
};
(mode, newly_pinned.then(|| pinned_snapshot(&g.map)))
};
if let Some(s) = snapshot {
self.persist(&s);
}
mode
}
fn confirm_at(&self, mac: &str, now: Instant) {
let snapshot = {
let mut g = self.inner.lock();
let Some(e) = g.map.get_mut(mac) else {
return;
};
e.awaiting_confirm = false;
e.last_seen = now;
// A proven non-default mode is worth remembering forever —
// the machine demonstrably can't use the default path.
if e.mode != DriverMode::Firmware && !e.pinned {
e.pinned = true;
Some(pinned_snapshot(&g.map))
} else {
None
}
};
if let Some(s) = snapshot {
self.persist(&s);
}
}
/// Best-effort atomic write of the learned-mode table. No-op for
/// ephemeral instances. Failure logs and moves on — persistence is an
/// optimization, never a correctness requirement.
fn persist(&self, snapshot: &HashMap<String, DriverMode>) {
let Some(path) = &self.path else { return };
let body = match serde_json::to_vec_pretty(snapshot) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::dhcp", "serialize driver_modes.json: {e}");
return;
}
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::dhcp", "write driver_modes.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, path.as_path()) {
tracing::warn!(target: "openpxe::dhcp", "rename driver_modes.json: {e}");
}
}
}
fn pinned_snapshot(map: &HashMap<String, Entry>) -> HashMap<String, DriverMode> {
map.iter()
.filter(|(_, e)| e.pinned)
.map(|(k, e)| (k.clone(), e.mode))
.collect()
}
fn evict_oldest(map: &mut HashMap<String, Entry>) {
// Prefer evicting an unpinned entry; only touch learned modes when
// the whole table is pinned (4096 learned machines — at that point
// the operator has bigger questions than our memory bound).
let pick = |pinned: bool| {
map.iter()
.filter(|(_, e)| e.pinned == pinned)
.min_by_key(|(_, e)| e.last_seen)
.map(|(k, _)| k.clone())
};
if let Some(oldest) = pick(false).or_else(|| pick(true)) {
map.remove(&oldest);
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn firmware_first_then_escalates_on_unconfirmed_retry() {
let e = DriverEscalation::new();
let t0 = Instant::now();
// Boot 1, primary DISCOVER: firmware.
assert_eq!(e.decide_at("aa", true, t0), DriverMode::Firmware);
// Same boot's :4011 query (+1s, within debounce): still firmware, no escalation.
assert_eq!(
e.decide_at("aa", false, t0 + Duration::from_secs(1)),
DriverMode::Firmware
);
// Firmware net failed → no iPXE handoff → machine re-PXE-boots much
// later: escalate to builtin drivers.
assert_eq!(
e.decide_at("aa", true, t0 + Duration::from_mins(1)),
DriverMode::Builtin
);
}
#[test]
fn builtin_is_sticky_after_success() {
let e = DriverEscalation::new();
let t0 = Instant::now();
assert_eq!(e.decide_at("bb", true, t0), DriverMode::Firmware);
assert_eq!(
e.decide_at("bb", true, t0 + Duration::from_mins(1)),
DriverMode::Builtin
);
// Builtin worked this time — confirm the handoff.
e.confirm_at("bb", t0 + Duration::from_secs(61));
// Next cold boot goes straight to builtin (no wasted firmware attempt).
assert_eq!(
e.decide_at("bb", true, t0 + Duration::from_mins(2)),
DriverMode::Builtin
);
}
#[test]
fn confirmed_firmware_never_escalates() {
let e = DriverEscalation::new();
let t0 = Instant::now();
assert_eq!(e.decide_at("cc", true, t0), DriverMode::Firmware);
// snponly worked: handoff confirmed.
e.confirm_at("cc", t0 + Duration::from_secs(2));
// A later boot stays on firmware — no spurious escalation.
assert_eq!(
e.decide_at("cc", true, t0 + Duration::from_mins(5)),
DriverMode::Firmware
);
}
#[test]
fn third_unconfirmed_attempt_escalates_to_shim_and_stays() {
// v0.7.0: a Secure-Boot client downloads-but-refuses both unsigned
// iPXE builds; the third boot gets the signed shim chain, and the
// MAC stays there for subsequent boots.
let e = DriverEscalation::new();
let t0 = Instant::now();
assert_eq!(e.decide_at("ee", true, t0), DriverMode::Firmware);
assert_eq!(
e.decide_at("ee", true, t0 + Duration::from_mins(1)),
DriverMode::Builtin
);
assert_eq!(
e.decide_at("ee", true, t0 + Duration::from_mins(2)),
DriverMode::Shim
);
// Shim is terminal — a fourth unconfirmed boot stays on Shim.
assert_eq!(
e.decide_at("ee", true, t0 + Duration::from_mins(3)),
DriverMode::Shim
);
}
#[test]
fn stale_unpinned_entry_is_forgotten_and_resets_to_firmware() {
let e = DriverEscalation::new();
let t0 = Instant::now();
assert_eq!(e.decide_at("dd", true, t0), DriverMode::Firmware);
assert_eq!(
e.decide_at("dd", true, t0 + Duration::from_mins(1)),
DriverMode::Builtin
);
// After the TTL with no activity the (unpinned) Builtin walk is
// pruned → fresh firmware. (A *confirmed* Builtin would be pinned
// and survive — see learned_builtin_survives_ttl.)
let later = t0 + Duration::from_mins(1) + ENTRY_TTL + Duration::from_secs(1);
assert_eq!(e.decide_at("dd", true, later), DriverMode::Firmware);
}
#[test]
fn shim_pin_survives_ttl() {
// v0.7.1: reaching the Shim rung is durable knowledge — the
// machine must NOT re-walk the ladder after an idle period.
let e = DriverEscalation::new();
let t0 = Instant::now();
let _ = e.decide_at("ff", true, t0);
let _ = e.decide_at("ff", true, t0 + Duration::from_mins(1));
assert_eq!(
e.decide_at("ff", true, t0 + Duration::from_mins(2)),
DriverMode::Shim
);
let much_later = t0 + Duration::from_mins(2) + ENTRY_TTL + Duration::from_mins(5);
assert_eq!(e.decide_at("ff", true, much_later), DriverMode::Shim);
}
#[test]
fn learned_builtin_survives_ttl() {
let e = DriverEscalation::new();
let t0 = Instant::now();
let _ = e.decide_at("gg", true, t0);
assert_eq!(
e.decide_at("gg", true, t0 + Duration::from_mins(1)),
DriverMode::Builtin
);
// The handoff confirms Builtin → pinned.
e.confirm_at("gg", t0 + Duration::from_secs(61));
let much_later = t0 + ENTRY_TTL + Duration::from_mins(10);
assert_eq!(e.decide_at("gg", true, much_later), DriverMode::Builtin);
}
#[test]
fn learned_modes_persist_across_restart() {
let dir = tempdir().unwrap();
let t0 = Instant::now();
{
let e = DriverEscalation::load_or_default(dir.path());
// Walk one MAC to Shim (pins on escalation)...
let _ = e.decide_at("aa:01", true, t0);
let _ = e.decide_at("aa:01", true, t0 + Duration::from_mins(1));
assert_eq!(
e.decide_at("aa:01", true, t0 + Duration::from_mins(2)),
DriverMode::Shim
);
// ...and another to a confirmed Builtin (pins on handoff).
let _ = e.decide_at("aa:02", true, t0);
let _ = e.decide_at("aa:02", true, t0 + Duration::from_mins(1));
e.confirm_at("aa:02", t0 + Duration::from_secs(61));
}
// "Restart": a fresh instance from the same work_dir knows both.
let e2 = DriverEscalation::load_or_default(dir.path());
assert_eq!(e2.decide_at("aa:01", true, t0), DriverMode::Shim);
assert_eq!(e2.decide_at("aa:02", true, t0), DriverMode::Builtin);
// Unlearned MACs still start at the default.
assert_eq!(e2.decide_at("aa:03", true, t0), DriverMode::Firmware);
}
#[test]
fn corrupt_persistence_file_starts_empty() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("driver_modes.json"), b"{broken").unwrap();
let e = DriverEscalation::load_or_default(dir.path());
assert_eq!(
e.decide_at("aa:bb", true, Instant::now()),
DriverMode::Firmware
);
}
#[test]
fn confirmed_firmware_is_not_persisted() {
// The default mode is never written — the file only carries
// exceptions, so a healthy fleet leaves it absent/empty.
let dir = tempdir().unwrap();
let t0 = Instant::now();
{
let e = DriverEscalation::load_or_default(dir.path());
let _ = e.decide_at("aa:09", true, t0);
e.confirm_at("aa:09", t0 + Duration::from_secs(2));
}
assert!(!dir.path().join("driver_modes.json").exists());
}
}
-2
View File
@@ -17,9 +17,7 @@
//! clients silently drop them. //! clients silently drop them.
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
pub mod escalation;
pub mod reply; pub mod reply;
pub mod server; pub mod server;
pub use escalation::DriverEscalation;
pub use server::DhcpProxyServer; pub use server::DhcpProxyServer;
+12 -37
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 openpxe_core::{ClientArch, DriverMode, 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.
@@ -31,11 +31,6 @@ pub struct ReplyContext<'a> {
pub our_ip: Ipv4Addr, pub our_ip: Ipv4Addr,
pub arch: ClientArch, pub arch: ClientArch,
pub class: FirmwareClass, pub class: FirmwareClass,
/// Which iPXE network backend to advertise for this client. The DHCP
/// proxy fills this from the automatic per-MAC escalation state: normally
/// [`DriverMode::Firmware`], escalated to [`DriverMode::Builtin`] for a
/// MAC whose firmware-net boot failed to chainload (v0.6.1).
pub driver_mode: DriverMode,
/// Public base URL (scheme://host[:port]) used in HTTP directives. /// Public base URL (scheme://host[:port]) used in HTTP directives.
pub public_base_url: &'a str, pub public_base_url: &'a str,
} }
@@ -49,39 +44,23 @@ pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
// Pass the client's MAC in the query string so the HTTP // Pass the client's MAC in the query string so the HTTP
// layer can short-circuit to a per-MAC binding when one // layer can short-circuit to a per-MAC binding when one
// exists. iPXE substitutes `${mac}` literally before issuing // exists. iPXE substitutes `${mac}` literally before issuing
// the GET, so this stays static across firmwares. The arch is // the GET, so this stays static across firmwares.
// known *here* from option 93, so it's baked in literally
// (v0.7.0) — it lets boot rules select on architecture.
url: format!( url: format!(
"{}/boot.ipxe?mac=${{mac}}&arch={}", "{}/boot.ipxe?mac=${{mac}}",
ctx.public_base_url.trim_end_matches('/'), ctx.public_base_url.trim_end_matches('/')
ctx.arch.as_str()
), ),
}, },
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
// pointing at an EFI executable. We serve the iPXE EFI build for // pointing at an EFI executable. We serve ipxe.efi over HTTP;
// the negotiated driver mode over HTTP; it'll then do the same // it'll then do the same script-fetch the iPXE path does.
// script-fetch the iPXE path does. let name = ctx.arch.ipxe_bootfile().unwrap_or("snponly.efi");
// `bootfile_with_fallback` (v0.7.0) walks back down the
// escalation ladder when the negotiated mode has no binary
// for this arch (e.g. Shim on an arch with no signed chain).
let name = ctx
.arch
.bootfile_with_fallback(ctx.driver_mode)
.unwrap_or("snponly.efi");
BootDirective::HttpScript { BootDirective::HttpScript {
url: format!( url: format!("{}/ipxe/{}", ctx.public_base_url.trim_end_matches('/'), name),
"{}/ipxe/{}",
ctx.public_base_url.trim_end_matches('/'),
name
),
} }
} }
FirmwareClass::PxeClient => match ctx.arch.bootfile_with_fallback(ctx.driver_mode) { FirmwareClass::PxeClient => match ctx.arch.ipxe_bootfile() {
Some(name) => BootDirective::TftpIpxe { Some(name) => BootDirective::TftpIpxe { filename: name.to_string() },
filename: name.to_string(),
},
None => BootDirective::Ignore, None => BootDirective::Ignore,
}, },
FirmwareClass::Other => BootDirective::Ignore, FirmwareClass::Other => BootDirective::Ignore,
@@ -128,16 +107,12 @@ pub fn build_reply(ctx: &ReplyContext<'_>, directive: &BootDirective) -> Option<
match directive { match directive {
BootDirective::TftpIpxe { filename } => { BootDirective::TftpIpxe { filename } => {
opts.insert(DhcpOption::TFTPServerName( opts.insert(DhcpOption::TFTPServerName(ctx.our_ip.to_string().into_bytes()));
ctx.our_ip.to_string().into_bytes(),
));
opts.insert(DhcpOption::BootfileName(filename.as_bytes().to_vec())); opts.insert(DhcpOption::BootfileName(filename.as_bytes().to_vec()));
} }
BootDirective::HttpScript { url } => { BootDirective::HttpScript { url } => {
opts.insert(DhcpOption::BootfileName(url.as_bytes().to_vec())); opts.insert(DhcpOption::BootfileName(url.as_bytes().to_vec()));
opts.insert(DhcpOption::TFTPServerName( opts.insert(DhcpOption::TFTPServerName(ctx.our_ip.to_string().into_bytes()));
ctx.our_ip.to_string().into_bytes(),
));
} }
BootDirective::Ignore => return None, BootDirective::Ignore => return None,
} }
+13 -83
View File
@@ -1,12 +1,11 @@
//! UDP listener loop for the DHCP proxy. Accepts on :67 (and :4011 on a //! UDP listener loop for the DHCP proxy. Accepts on :67 (and :4011 on a
//! second socket) and dispatches each datagram through the pure reply logic. //! second socket) and dispatches each datagram through the pure reply logic.
use crate::escalation::DriverEscalation;
use crate::reply::{build_reply, decide, BootDirective, ReplyContext}; use crate::reply::{build_reply, decide, BootDirective, ReplyContext};
use dhcproto::v4::{DhcpOption, Message, OptionCode}; use dhcproto::v4::{DhcpOption, Message, OptionCode};
use dhcproto::{Decodable, Decoder, Encodable, Encoder}; use dhcproto::{Decodable, Decoder, Encodable, Encoder};
use openpxe_core::{ use openpxe_core::{
BootRulesStore, ClientArch, ClientEvent, ClientRegistry, DriverMode, FirmwareClass, ClientArch, ClientEvent, ClientRegistry, FirmwareClass,
}; };
use socket2::{Domain, Protocol, Socket, Type}; use socket2::{Domain, Protocol, Socket, Type};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
@@ -21,19 +20,9 @@ pub struct DhcpProxyServer {
public_base_url: String, public_base_url: String,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics, metrics: openpxe_core::Metrics,
/// Automatic per-MAC NIC driver-mode escalation (v0.6.1; persistent
/// learned modes since v0.7.1). Shared across the :67 and :4011
/// listener tasks via the server `Arc`. Built by the caller so the
/// persistence path comes from the configured work dir.
escalation: DriverEscalation,
/// v0.7.1: boot rules — consulted for an operator driver-mode pin
/// (e.g. "this OUI is all Secure Boot → serve shim immediately")
/// before the automatic escalation ladder.
rules: BootRulesStore,
} }
impl DhcpProxyServer { impl DhcpProxyServer {
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
bind: IpAddr, bind: IpAddr,
dhcp_port: u16, dhcp_port: u16,
@@ -42,8 +31,6 @@ impl DhcpProxyServer {
public_base_url: String, public_base_url: String,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics, metrics: openpxe_core::Metrics,
escalation: DriverEscalation,
rules: BootRulesStore,
) -> Self { ) -> Self {
Self { Self {
bind, bind,
@@ -53,8 +40,6 @@ impl DhcpProxyServer {
public_base_url, public_base_url,
clients, clients,
metrics, metrics,
escalation,
rules,
} }
} }
@@ -101,22 +86,11 @@ impl DhcpProxyServer {
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let request = Message::decode(&mut Decoder::new(data))?; let request = Message::decode(&mut Decoder::new(data))?;
let vendor_class = request let vendor_class = request.opts().get(OptionCode::ClassIdentifier).and_then(|o| {
.opts() if let DhcpOption::ClassIdentifier(v) = o { Some(v.as_slice()) } else { None }
.get(OptionCode::ClassIdentifier) });
.and_then(|o| {
if let DhcpOption::ClassIdentifier(v) = o {
Some(v.as_slice())
} else {
None
}
});
let user_class = request.opts().get(OptionCode::UserClass).and_then(|o| { let user_class = request.opts().get(OptionCode::UserClass).and_then(|o| {
if let DhcpOption::UserClass(v) = o { if let DhcpOption::UserClass(v) = o { Some(v.as_slice()) } else { None }
Some(v.as_slice())
} else {
None
}
}); });
let class = FirmwareClass::classify(vendor_class, user_class); let class = FirmwareClass::classify(vendor_class, user_class);
if matches!(class, FirmwareClass::Other) { if matches!(class, FirmwareClass::Other) {
@@ -143,38 +117,11 @@ impl DhcpProxyServer {
}, },
); );
// Automatic NIC driver-mode selection (v0.6.1). The default is
// firmware-net (snponly/undionly). A successful iPXE handoff confirms
// the current mode works for this MAC; a fresh firmware boot whose
// predecessor never handed off escalates the MAC to iPXE's built-in
// NIC drivers. No operator toggle — the firmware path is unchanged so
// hardware that already boots never regresses.
let driver_mode = match class {
FirmwareClass::IpxeUserClass => {
self.escalation.mark_ipxe_success(&mac);
DriverMode::Firmware // unused: this path serves the HTTP script
}
FirmwareClass::PxeClient | FirmwareClass::HttpClient => {
// v0.7.1: an operator rule pin wins over (and bypasses)
// the automatic escalation ladder — known Secure-Boot
// fleets boot the signed chain on the very first cycle.
if let Some(pinned) = self.rules.driver_mode_hint(&mac, Some(arch.as_str())) {
pinned
} else {
self.escalation
.mode_for_firmware_attempt(&mac, label == "67")
}
}
// Unreachable: FirmwareClass::Other returned above.
FirmwareClass::Other => DriverMode::Firmware,
};
let ctx = ReplyContext { let ctx = ReplyContext {
request: &request, request: &request,
our_ip: self.our_ip, our_ip: self.our_ip,
arch, arch,
class, class,
driver_mode,
public_base_url: &self.public_base_url, public_base_url: &self.public_base_url,
}; };
let directive = decide(&ctx); let directive = decide(&ctx);
@@ -188,9 +135,7 @@ impl DhcpProxyServer {
} }
self.metrics.record_dhcp_reply(arch.as_str()); self.metrics.record_dhcp_reply(arch.as_str());
let Some(reply) = build_reply(&ctx, &directive) else { let Some(reply) = build_reply(&ctx, &directive) else { return Ok(()); };
return Ok(());
};
let mut out = Vec::with_capacity(512); let mut out = Vec::with_capacity(512);
reply.encode(&mut Encoder::new(&mut out))?; reply.encode(&mut Encoder::new(&mut out))?;
@@ -198,7 +143,7 @@ impl DhcpProxyServer {
sock.send_to(&out, dest).await?; sock.send_to(&out, dest).await?;
tracing::info!( tracing::info!(
target: "openpxe::dhcp", target: "openpxe::dhcp",
mac=%mac, arch=arch.as_str(), class=?class, driver=?driver_mode, dest=%dest, directive=?directive, mac=%mac, arch=arch.as_str(), class=?class, dest=%dest, directive=?directive,
"PXE reply sent" "PXE reply sent"
); );
Ok(()) Ok(())
@@ -257,16 +202,8 @@ fn bind_udp(bind: IpAddr, port: u16, broadcast: bool) -> anyhow::Result<UdpSocke
} }
fn format_mac(chaddr: &[u8]) -> String { fn format_mac(chaddr: &[u8]) -> String {
use std::fmt::Write; let take = chaddr.iter().take(6).copied().collect::<Vec<_>>();
// One allocation — this runs for every PXE datagram we answer. take.iter().map(|b| format!("{b:02x}")).collect::<Vec<_>>().join(":")
let mut s = String::with_capacity(17);
for (i, b) in chaddr.iter().take(6).enumerate() {
if i > 0 {
s.push(':');
}
let _ = write!(s, "{b:02x}");
}
s
} }
/// Walk raw DHCP options looking for option 93 (Client System Architecture) /// Walk raw DHCP options looking for option 93 (Client System Architecture)
@@ -280,17 +217,10 @@ fn extract_raw_arch(packet: &[u8]) -> Option<u16> {
let mut i = 0; let mut i = 0;
while i < opts.len() { while i < opts.len() {
let code = opts[i]; let code = opts[i];
if code == 0xff { if code == 0xff { return None; } // END
return None; if code == 0x00 { i += 1; continue; } // PAD
} // END
if code == 0x00 {
i += 1;
continue;
} // PAD
i += 1; i += 1;
if i >= opts.len() { if i >= opts.len() { return None; }
return None;
}
let len = opts[i] as usize; let len = opts[i] as usize;
i += 1; i += 1;
if code == 93 && len >= 2 && i + 2 <= opts.len() { if code == 93 && len >= 2 && i + 2 <= opts.len() {
@@ -310,7 +240,7 @@ mod tests {
// Minimal BOOTP header + magic cookie + option 93 (arch)=0x0007 + END. // Minimal BOOTP header + magic cookie + option 93 (arch)=0x0007 + END.
let mut pkt = vec![0u8; 240]; let mut pkt = vec![0u8; 240];
pkt[236..240].copy_from_slice(&[99, 130, 83, 99]); // magic cookie pkt[236..240].copy_from_slice(&[99, 130, 83, 99]); // magic cookie
pkt.extend_from_slice(&[53, 1, 1]); // option 53 DHCPDISCOVER pkt.extend_from_slice(&[53, 1, 1]); // option 53 DHCPDISCOVER
pkt.extend_from_slice(&[93, 2, 0x00, 0x07]); pkt.extend_from_slice(&[93, 2, 0x00, 0x07]);
pkt.push(0xff); pkt.push(0xff);
assert_eq!(extract_raw_arch(&pkt), Some(0x0007)); assert_eq!(extract_raw_arch(&pkt), Some(0x0007));
+2 -29
View File
@@ -4,10 +4,6 @@ version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
# v0.5.0: inherit the workspace repository so CARGO_PKG_REPOSITORY is
# populated at build time — the About-tab update check derives the
# Gitea releases API URL from it.
repository.workspace = true
description = "HTTP server: ISO uploads, iPXE script generation, ISO streaming" description = "HTTP server: ISO uploads, iPXE script generation, ISO streaming"
[lints] [lints]
@@ -33,17 +29,8 @@ thiserror.workspace = true
anyhow.workspace = true anyhow.workspace = true
bytes.workspace = true bytes.workspace = true
futures.workspace = true futures.workspace = true
uuid.workspace = true mime.workspace = true
# v0.4.5 Forms auth: lock-free session store and cookie helpers. mime_guess.workspace = true
parking_lot.workspace = true
# v0.5.0: outbound HTTP for chat webhooks (Slack/Teams/Discord) and the
# About-tab "check for updates" call to the Gitea releases API; SMTP for
# email notifications. Both use rustls so the static musl binary stays
# OpenSSL-free.
reqwest.workspace = true
lettre.workspace = true
# v0.5.1: decode the base64 SAMLResponse at the ACS endpoint.
base64.workspace = true
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] } tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] }
@@ -51,17 +38,3 @@ tower = { workspace = true }
tempfile = "3.12" tempfile = "3.12"
serde_json = { workspace = true } serde_json = { workspace = true }
time = { workspace = true } time = { workspace = true }
# v0.4.61: integration tests need to generate real PNG bytes for the
# `/branding/pxe-logo` compositor; hand-rolled CRCs are too fragile.
image = { version = "0.25", default-features = false, features = ["png"] }
# v0.5.1: the SAML ACS integration tests mint a throwaway IdP keypair
# (rcgen) and sign a SAMLResponse with bergshamra so the happy-path,
# replay, and IdP-initiated-gating flows exercise real signatures.
rcgen = "0.13"
bergshamra = { workspace = true }
# v0.5.4: snapshot the generated iPXE menu so any unintended drift (a
# dropped line, reordered item) is caught and reviewed, not silently shipped.
insta = "1.40"
# v0.5.4: stand up a mock HTTP server to exercise the SAML metadata-URL
# fetch path (previously untested because it did a real network GET).
wiremock = "0.6"
+168 -2650
View File
File diff suppressed because it is too large Load Diff
-556
View File
@@ -1,556 +0,0 @@
//! Forms auth layer — sessions, login, setup, middleware.
//!
//! Three states:
//!
//! * **Unconfigured** (`AdminStore::is_configured() == false`). The
//! middleware passes every request through — there's no one to gate
//! against. The UI's `/api/me` returns `setup_required: true` and the
//! front-end pushes the operator into the first-run flow.
//! * **Logged in**. The session cookie maps to an in-memory session
//! record with an idle expiry; `/api/me` returns the username.
//! * **Logged out**. The middleware bounces `/api/*` (with the PXE
//! allowlist below) to `401 Unauthorized`; the front-end intercepts
//! that and shows `/login`.
//!
//! Allowlist for unauthenticated access *after* the admin is set up:
//!
//! * everything outside `/api/*` (the WebUI bundle, asset chrome, PXE
//! script endpoints, the bundled iPXE/wimboot binaries, ISO bytes,
//! liveness/readiness probes, the Prometheus scrape) — these are
//! read-only or PXE-essential and breaking them locks out booting
//! machines that have no way to authenticate;
//! * `/api/setup`, `/api/login`, `/api/me` (the auth surface itself);
//! * `/api/queue/join`, `/api/queue/poll/:entry_id` (iPXE long-poll for
//! Queued Deployment — the iPXE client can't send a session cookie).
//!
//! Everything else inside `/api/*` requires a valid session.
use crate::state::AppState;
use axum::{
body::Body,
extract::{Request, State},
http::{header, HeaderValue, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Json,
};
use openpxe_core::AdminPublic;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use uuid::Uuid;
/// Idle session lifetime. Sliding — every authenticated request resets
/// the expiry. 24h is the Sonarr default and matches what most operators
/// expect for an on-prem admin console.
const SESSION_TTL: Duration = Duration::from_hours(24);
/// Name of the cookie we set/read. Distinct from a generic `session=`
/// to avoid collisions with anything else sharing the host.
pub const SESSION_COOKIE: &str = "openpxe_session";
#[derive(Debug, Clone)]
struct Session {
username: String,
expires_at: Instant,
}
/// In-memory session table. Cheap to clone (Arc-shared) and contention
/// is rare — operators sign in once per browser session.
#[derive(Debug, Clone, Default)]
pub struct SessionStore {
inner: Arc<RwLock<HashMap<String, Session>>>,
}
impl SessionStore {
/// Mint a fresh session for `username` and return the opaque cookie
/// value. UUID v4 gives us 122 random bits — comfortably more than
/// the 64-128 bits typical for session IDs.
#[must_use]
pub fn create(&self, username: &str) -> String {
let id = Uuid::new_v4().simple().to_string();
let session = Session {
username: username.to_string(),
expires_at: Instant::now() + SESSION_TTL,
};
self.inner.write().insert(id.clone(), session);
id
}
/// Resolve a cookie value to the owning username, refreshing the
/// idle timer. Returns `None` for missing / expired sessions and
/// proactively evicts the expired entry so the map doesn't grow
/// unbounded across long-lived deployments.
pub fn touch(&self, id: &str) -> Option<String> {
let mut g = self.inner.write();
let s = g.get_mut(id)?;
if s.expires_at <= Instant::now() {
g.remove(id);
return None;
}
s.expires_at = Instant::now() + SESSION_TTL;
Some(s.username.clone())
}
/// Invalidate one session (the user's `/api/logout`).
pub fn revoke(&self, id: &str) {
self.inner.write().remove(id);
}
/// Invalidate every session — used after a credentials rotation so
/// stale cookies for the old password can't keep operating.
pub fn revoke_all(&self) {
self.inner.write().clear();
}
/// Periodic / opportunistic GC. Not currently scheduled (we evict
/// on touch), but exposed for a future janitor task.
pub fn gc(&self) {
let now = Instant::now();
self.inner.write().retain(|_, s| s.expires_at > now);
}
#[must_use]
pub fn len(&self) -> usize {
self.inner.read().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
// ── Cookie helpers ────────────────────────────────────────────────────────
fn cookie_attrs(value: &str, max_age: Option<i64>) -> String {
// Same flags FleetDM and Sonarr ship by default:
// - HttpOnly: blocks JS access (XSS containment)
// - SameSite=Lax: allows top-level GET navigations from the IdP
// to land authenticated when SSO arrives, but blocks
// cross-site POST CSRF;
// - Path=/: the cookie applies to the whole app;
// - no Secure flag yet — many operators host on plain http://
// LAN IPs (Unraid templates default to that); we'll add Secure
// opportunistically when we add a TLS terminator option.
// SESSION_TTL fits in 32 bits comfortably (24h ≈ 86400 seconds); we
// never overflow i64, but clippy's `cast_possible_wrap` lint wants
// us to be explicit. `cast_signed` is the documented form.
let lifetime = max_age.unwrap_or_else(|| SESSION_TTL.as_secs().cast_signed());
format!("{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={lifetime}")
}
/// Build the `Set-Cookie` header value that establishes a fresh operator
/// session with the default 24h TTL. Exposed so the SAML ACS handler can
/// attach an operator session to its post-login redirect, exactly as the
/// Forms-login path does via [`login_response`].
#[must_use]
pub fn session_cookie(session: &str) -> String {
cookie_attrs(session, None)
}
fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
// `Cookie: a=b; c=d` parsing — small enough not to drag in a crate.
// Two-step strip (name, then '=') keeps this allocation-free per
// candidate and can't match a longer cookie name sharing the prefix.
let raw = headers.get(header::COOKIE)?.to_str().ok()?;
for part in raw.split(';') {
let part = part.trim();
if let Some(v) = part
.strip_prefix(SESSION_COOKIE)
.and_then(|rest| rest.strip_prefix('='))
{
return Some(v.to_string());
}
}
None
}
/// Does this request carry a live operator session? Used by endpoints
/// outside the `/api/*` middleware that still want to honor a logged-in
/// operator (e.g. browser-testing a token-gated answer file, v0.7.0).
pub(crate) fn session_authenticated(state: &AppState, headers: &axum::http::HeaderMap) -> bool {
parse_cookie(headers).is_some_and(|t| state.sessions.touch(&t).is_some())
}
// ── Middleware ────────────────────────────────────────────────────────────
/// Return `true` if `path` is on the allowlist and should bypass the
/// session check. The middleware applies this rule only when the admin
/// account is configured; before then everything is open.
fn is_public_path(path: &str) -> bool {
// Non-API paths: WebUI bundle, PXE chain, ISO bytes, health probes,
// metrics. All read-only / PXE-essential.
if !path.starts_with("/api/") {
return true;
}
// Auth surface and iPXE long-poll endpoints (no cookie available).
// The SAML SP endpoints are pre-auth by nature — the operator hasn't a
// session yet when they start (or arrive from) the IdP. `/api/sso`
// (the config GET/PUT, no trailing slash) stays gated.
matches!(
path,
"/api/setup"
| "/api/login"
| "/api/logout"
| "/api/me"
| "/api/sso/login"
| "/api/sso/acs"
| "/api/sso/metadata"
) || path.starts_with("/api/queue/join")
|| path.starts_with("/api/queue/poll/")
}
/// Axum middleware: gate `/api/*` behind a valid session, with the
/// allowlist above. `State<AppState>` reaches in for the admin store +
/// session store.
pub async fn require_auth(
State(state): State<AppState>,
req: Request<Body>,
next: Next,
) -> Response {
// Bypass entirely while unconfigured. The /api/setup endpoint is
// the only one that can flip this back to "configured", and it
// refuses to run a second time. Tests + fresh installs ride this
// path.
if !state.admin.is_configured() {
return next.run(req).await;
}
let path = req.uri().path();
if is_public_path(path) {
return next.run(req).await;
}
// Authenticated path. The cookie must be present, map to a live
// session, and the TTL refresh happens as a side-effect.
let token = parse_cookie(req.headers());
if let Some(t) = token {
if state.sessions.touch(&t).is_some() {
return next.run(req).await;
}
}
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "authentication required" })),
)
.into_response()
}
// ── Handlers ──────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct SetupBody {
pub username: String,
pub password: String,
}
/// First-run setup. Refuses to run once an admin already exists — that
/// guards against a leaked WebUI being re-bootstrapped by an attacker
/// who's seen the deployment URL. After bootstrap, the new session
/// cookie is set so the operator goes straight to the dashboard.
pub async fn api_setup(State(state): State<AppState>, Json(body): Json<SetupBody>) -> Response {
if state.admin.is_configured() {
return (
StatusCode::CONFLICT,
Json(json!({ "error": "admin account already configured" })),
)
.into_response();
}
// bcrypt hashing is ~100-200 ms of pure CPU (and `bootstrap` also
// persists to disk synchronously) — keep it off the async workers.
let admin = state.admin.clone();
let result =
tokio::task::spawn_blocking(move || admin.bootstrap(&body.username, &body.password))
.await
.unwrap_or_else(|e| Err(openpxe_core::Error::Other(e.into())));
match result {
Ok(pub_) => {
let session = state.sessions.create(&pub_.username);
login_response(StatusCode::CREATED, &pub_, &session)
}
Err(openpxe_core::Error::Invalid(msg)) => {
(StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("{e}") })),
)
.into_response(),
}
}
#[derive(Debug, Deserialize)]
pub struct LoginBody {
pub username: String,
pub password: String,
}
pub async fn api_login(State(state): State<AppState>, Json(body): Json<LoginBody>) -> Response {
// Brief, deliberately vague — "invalid credentials" rather than
// "no such user" / "wrong password". Same anti-enumeration posture
// as Sonarr/Radarr. The bcrypt verify is ~100-200 ms of pure CPU on
// an unauthenticated endpoint, so it runs on the blocking pool.
let admin = state.admin.clone();
let verdict = tokio::task::spawn_blocking(move || admin.verify(&body.username, &body.password))
.await
.unwrap_or_else(|e| Err(openpxe_core::Error::Other(e.into())));
let pub_ = match verdict {
Ok(Some(u)) => u,
Ok(None) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "invalid username or password" })),
)
.into_response();
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("{e}") })),
)
.into_response();
}
};
let session = state.sessions.create(&pub_.username);
login_response(StatusCode::OK, &pub_, &session)
}
pub async fn api_logout(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
if let Some(t) = parse_cookie(&headers) {
state.sessions.revoke(&t);
}
// Stomp the cookie unconditionally — even if the request didn't
// carry one, the browser shouldn't keep a stale value.
let mut resp = StatusCode::NO_CONTENT.into_response();
resp.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_str(&cookie_attrs("", Some(0))).unwrap(),
);
resp
}
/// Status surface for the front-end shell. Returns four cases:
///
/// * `setup_required: true` — no admin yet; show first-run page.
/// * `authenticated: false` — admin exists, no session; show login.
/// * `authenticated: true` + `user` — let the dashboard load.
pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
// v0.5.0: include branding bootstrap so the pre-auth login/setup
// screens can render the FleetDM-style full-width custom logo (and
// cache-bust it) without an extra round trip. `/api/me` is public,
// and the logo asset is public, so this leaks nothing sensitive.
let has_custom_logo = state.branding.has_any_web_logo();
let logo_rev = state.branding.logo_rev();
// v0.5.9: ship the non-sensitive SSO descriptor with every /api/me so
// the pre-auth login screen can render the "Sign in with …" button
// reliably. Previously the button keyed off the auth-gated /api/sso,
// which 401s when logged out — the button only survived on a stale
// in-memory config and vanished on any fresh login-page load.
let sso = state.sso.login_info();
if !state.admin.is_configured() {
return (
StatusCode::OK,
Json(json!({
"setup_required": true,
"authenticated": false,
"has_custom_logo": has_custom_logo,
"logo_rev": logo_rev,
"sso": sso,
})),
)
.into_response();
}
let token = parse_cookie(&headers);
let username = token.as_deref().and_then(|t| state.sessions.touch(t));
match username {
Some(u) => (
StatusCode::OK,
Json(json!({
"setup_required": false,
"authenticated": true,
"user": state.admin.snapshot(),
"session_user": u,
"has_custom_logo": has_custom_logo,
"logo_rev": logo_rev,
"sso": sso,
})),
)
.into_response(),
None => (
StatusCode::OK,
Json(json!({
"setup_required": false,
"authenticated": false,
"has_custom_logo": has_custom_logo,
"logo_rev": logo_rev,
"sso": sso,
})),
)
.into_response(),
}
}
#[derive(Debug, Deserialize)]
pub struct UpdateCredentialsBody {
pub current_password: String,
#[serde(default)]
pub new_username: Option<String>,
#[serde(default)]
pub new_password: Option<String>,
}
/// Rotate the admin's username and/or password. Auth middleware has
/// already proved the caller owns a session; we additionally require
/// the *current* password to prove "person at the keyboard right now".
/// On success we issue a fresh session cookie keyed to the (possibly
/// new) username and revoke every prior session so a stolen cookie
/// from before the rotation stops working.
pub async fn api_update_credentials(
State(state): State<AppState>,
Json(body): Json<UpdateCredentialsBody>,
) -> Response {
if !state.admin.is_configured() {
return (
StatusCode::CONFLICT,
Json(json!({ "error": "no admin configured" })),
)
.into_response();
}
// Two bcrypt operations (verify current + hash new) plus a sync disk
// persist — run the lot on the blocking pool.
let admin = state.admin.clone();
let result = tokio::task::spawn_blocking(move || {
admin.update_credentials(
&body.current_password,
body.new_username.as_deref(),
body.new_password.as_deref(),
)
})
.await
.unwrap_or_else(|e| Err(openpxe_core::Error::Other(e.into())));
match result {
Ok(pub_) => {
state.sessions.revoke_all();
let session = state.sessions.create(&pub_.username);
login_response(StatusCode::OK, &pub_, &session)
}
Err(openpxe_core::Error::Invalid(msg)) => {
(StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("{e}") })),
)
.into_response(),
}
}
#[derive(Debug, Serialize)]
struct LoginPayload<'a> {
user: &'a AdminPublic,
authenticated: bool,
}
fn login_response(status: StatusCode, user: &AdminPublic, session: &str) -> Response {
let body = Json(LoginPayload {
user,
authenticated: true,
});
let mut resp = (status, body).into_response();
resp.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_str(&cookie_attrs(session, None)).unwrap(),
);
resp
}
// ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_create_touch_revoke() {
let s = SessionStore::default();
assert!(s.is_empty());
let t = s.create("admin");
assert_eq!(s.len(), 1);
assert_eq!(s.touch(&t).as_deref(), Some("admin"));
s.revoke(&t);
assert!(s.is_empty());
// Stale token doesn't error, just returns None.
assert!(s.touch(&t).is_none());
}
#[test]
fn session_revoke_all_clears() {
let s = SessionStore::default();
let _ = s.create("a");
let _ = s.create("b");
assert_eq!(s.len(), 2);
s.revoke_all();
assert!(s.is_empty());
}
#[test]
fn public_path_allowlist() {
// PXE + chrome paths bypass auth.
for p in [
"/",
"/assets/app.js",
"/boot.ipxe",
"/boot/fake.ipxe",
"/iso/fake.iso",
"/ipxe/snponly.efi",
"/healthz",
"/readyz",
"/metrics",
// v0.4.6: iPXE fetches this for `console --picture` before
// it can possibly have a session cookie.
"/branding/pxe-logo",
] {
assert!(is_public_path(p), "expected {p} to be public");
}
// Auth surface itself is public.
for p in ["/api/setup", "/api/login", "/api/logout", "/api/me"] {
assert!(is_public_path(p), "expected {p} to be public");
}
// v0.5.1: SAML SP endpoints are pre-auth (no session yet).
for p in ["/api/sso/login", "/api/sso/acs", "/api/sso/metadata"] {
assert!(is_public_path(p), "expected {p} to be public");
}
// iPXE long-poll endpoints are public (no cookie available).
assert!(is_public_path("/api/queue/join"));
assert!(is_public_path("/api/queue/poll/abc"));
// Everything else under /api/* must auth.
for p in [
"/api/isos",
"/api/isos/x/category",
"/api/storage/disk",
"/api/branding/logo",
"/api/sso",
"/api/hosts",
] {
assert!(!is_public_path(p), "expected {p} to require auth");
}
}
#[test]
fn cookie_parse_picks_session_value() {
let mut h = axum::http::HeaderMap::new();
h.insert(
header::COOKIE,
HeaderValue::from_str(&format!("foo=bar; {SESSION_COOKIE}=abc123; baz=qux")).unwrap(),
);
assert_eq!(parse_cookie(&h).as_deref(), Some("abc123"));
// Different name → None.
let mut h2 = axum::http::HeaderMap::new();
h2.insert(header::COOKIE, HeaderValue::from_str("foo=bar").unwrap());
assert!(parse_cookie(&h2).is_none());
// No cookie header at all → None.
assert!(parse_cookie(&axum::http::HeaderMap::new()).is_none());
}
}
-92
View File
@@ -1,92 +0,0 @@
//! Uniform HTTP error mapping for the API layer (v0.5.4).
//!
//! Before this, ~40 handlers in `app.rs` hand-wrote
//! `match … { Err(e) => (StatusCode::…, format!("{e}")).into_response() }`,
//! and the `openpxe_core::Error` → status mapping drifted between them
//! (e.g. `Invalid` → 400 in most places, 404 in one). [`AppError`] wraps
//! `openpxe_core::Error` so a handler can return `Result<T, AppError>` and
//! `?` its way out, getting one consistent status + body. The body stays
//! plain-text (matching the previous `(StatusCode, String)` responses) so
//! existing clients and tests see no shape change; 5xx detail is logged
//! and returned verbatim exactly as before.
//!
//! Handlers with *intentional* domain-specific statuses (e.g. a duplicate
//! share → 409, a still-open chunked upload → 409) keep their explicit
//! returns — `AppError` is for the common case, not a straitjacket.
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use openpxe_core::Error as CoreError;
/// Newtype over [`openpxe_core::Error`] with a uniform [`IntoResponse`].
#[derive(Debug)]
pub struct AppError(pub CoreError);
impl From<CoreError> for AppError {
fn from(e: CoreError) -> Self {
AppError(e)
}
}
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
AppError(CoreError::Io(e))
}
}
impl AppError {
/// The HTTP status this error maps to. Public so handlers (and tests)
/// can reason about the mapping in one place.
#[must_use]
pub fn status(&self) -> StatusCode {
match self.0 {
CoreError::NotFound(_) => StatusCode::NOT_FOUND,
CoreError::Invalid(_) => StatusCode::BAD_REQUEST,
CoreError::Config(_) | CoreError::Io(_) | CoreError::Other(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = self.status();
// Match the prior hand-written responses: the 4xx arms returned the
// bare inner message (not the `Display` prefix), so a UI showing
// `await r.text()` reads "metadata too long", not "invalid input:
// metadata too long". 5xx keeps the full `Display` string.
let body = match &self.0 {
CoreError::Invalid(m) | CoreError::NotFound(m) => m.clone(),
other => other.to_string(),
};
if status.is_server_error() {
// Log the full detail server-side; the body still carries it
// (unchanged from the prior `format!("{e}")` behaviour), but the
// log line is what an operator greps for.
tracing::error!(target: "openpxe::http", error = %self.0, "request failed");
}
(status, body).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_mapping_is_consistent() {
assert_eq!(
AppError(CoreError::NotFound("x".into())).status(),
StatusCode::NOT_FOUND
);
assert_eq!(
AppError(CoreError::Invalid("x".into())).status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
AppError(CoreError::Config("x".into())).status(),
StatusCode::INTERNAL_SERVER_ERROR
);
}
}
-192
View File
@@ -1,192 +0,0 @@
//! GRUB menu rendering for the Secure Boot chain (v0.7.0).
//!
//! Secure-Boot-enabled firmware refuses our unsigned iPXE, so those
//! clients are automatically escalated (see `openpxe_dhcp_proxy::
//! escalation`) to the Microsoft-signed Fedora `shim` → signed `grub`
//! chain. GRUB then fetches `grub.cfg` from this server (TFTP `$prefix`
//! resolution, or HTTP when the whole chain came over HTTP Boot) — and
//! this module renders that config from the same boot-entry model that
//! renders `boot.ipxe`.
//!
//! Scope: **Linux kernel entries only.** A signed GRUB will only execute
//! kernels that pass shim verification — i.e. distro-signed kernels —
//! which is exactly what `LinuxKernel` boot entries point at. `sanboot`
//! ISO emulation and `wimboot` are iPXE mechanisms with no signed
//! equivalent; those entries are omitted here, and the menu says so.
//! (Windows deployment under Secure Boot has no legitimate unsigned
//! path — per project policy we never ship test-signed binaries or touch
//! client trust stores.)
//!
//! The kernel/initrd lines use GRUB's `(http,host:port)` device syntax;
//! Fedora's signed netboot GRUB carries the `http`, `tftp` and `efinet`
//! modules built in, so no unsigned module loading is required.
use openpxe_iso_store::{BootKind, IsoMeta};
use std::fmt::Write as _;
/// Render the full `grub.cfg` for the signed-GRUB menu.
///
/// `base_url` is the public HTTP base (`http://10.0.0.5` or
/// `http://10.0.0.5:8080`) — converted to GRUB's `(http,host:port)`
/// device prefix for kernel/initrd fetches.
#[must_use]
pub fn render_grub_menu(isos: &[IsoMeta], base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let dev = grub_http_device(base);
let mut s = String::new();
let _ = writeln!(s, "# OpenPXE — Secure Boot menu (signed shim+GRUB chain)");
// v0.7.1: before showing the limited signed menu, try to hand the
// boot back to full iPXE *in this same boot cycle*. With Secure Boot
// OFF the chainload succeeds and the client gets the complete iPXE
// feature set (sanboot, wimboot, the full menu) despite having been
// escalated here. With Secure Boot ON, shim's verifier refuses the
// unsigned image INLINE — no reboot, no failed cycle — and execution
// falls through to the signed menu below. The all-drivers build is
// used because a MAC only lands here after the firmware-net build
// already failed once.
let _ = writeln!(s, "if [ \"$grub_cpu\" = \"arm64\" ]; then");
let _ = writeln!(s, " set openpxe_ipxe=ipxe-arm64.efi");
let _ = writeln!(s, "else");
let _ = writeln!(s, " set openpxe_ipxe=ipxe.efi");
let _ = writeln!(s, "fi");
let _ = writeln!(s, "if chainloader {dev}/ipxe/$openpxe_ipxe ; then");
let _ = writeln!(s, " boot");
let _ = writeln!(s, "fi");
let _ = writeln!(s);
let _ = writeln!(s, "set timeout=30");
let _ = writeln!(s, "set default=0");
let _ = writeln!(s);
let mut entries = 0usize;
for iso in isos {
for entry in &iso.boot_entries {
let BootKind::LinuxKernel {
kernel_url,
initrd_urls,
args,
} = &entry.kind
else {
continue;
};
// GRUB menu titles: keep quotes out of the label.
let title = entry.title.replace('"', "'");
let cmdline = args.cmdline.replace("${base-url}", base);
let _ = writeln!(s, "menuentry \"{} — {title}\" {{", iso.filename);
let _ = writeln!(s, " linux {dev}/{kernel_url} {cmdline}");
if !initrd_urls.is_empty() {
let _ = write!(s, " initrd");
for u in initrd_urls {
let _ = write!(s, " {dev}/{u}");
}
let _ = writeln!(s);
}
let _ = writeln!(s, "}}");
let _ = writeln!(s);
entries += 1;
}
}
if entries == 0 {
let _ = writeln!(
s,
"menuentry \"No Secure-Boot-bootable images on this server yet\" {{ true }}"
);
let _ = writeln!(s);
}
// Always give the operator a way off this screen.
let _ = writeln!(s, "menuentry \"Boot from local disk\" {{");
let _ = writeln!(s, " exit");
let _ = writeln!(s, "}}");
s
}
/// `http://10.0.0.5:8080` → `(http,10.0.0.5:8080)`. GRUB wants the
/// scheme as the device type and host[:port] as the device address.
fn grub_http_device(base: &str) -> String {
let host = base
.trim_start_matches("http://")
.trim_start_matches("https://");
format!("(http,{host})")
}
#[cfg(test)]
mod tests {
use super::*;
use openpxe_iso_store::{BootEntry, IsoSource, KernelArgs};
fn linux_iso() -> IsoMeta {
IsoMeta {
id: "alp".into(),
filename: "alpine.iso".into(),
size_bytes: 1,
sha256_hex: None,
uploaded_at: time::OffsetDateTime::UNIX_EPOCH,
source: IsoSource::Local,
introspection: openpxe_iso_store::IntrospectionReport::default(),
boot_entries: vec![BootEntry {
id: "alp-linux".into(),
title: "Linux installer".into(),
kind: BootKind::LinuxKernel {
kernel_url: "iso/alp/boot/vmlinuz".into(),
initrd_urls: vec!["iso/alp/boot/initrd".into()],
args: KernelArgs {
cmdline: "quiet repo=${base-url}/iso/alp.iso".into(),
},
},
}],
category: openpxe_iso_store::IsoCategory::default(),
password_hash: None,
}
}
#[test]
fn renders_linux_entries_with_http_device_urls() {
let cfg = render_grub_menu(&[linux_iso()], "http://10.0.0.5:8080/");
assert!(
cfg.contains("menuentry \"alpine.iso — Linux installer\""),
"{cfg}"
);
assert!(
cfg.contains("linux (http,10.0.0.5:8080)/iso/alp/boot/vmlinuz quiet repo=http://10.0.0.5:8080/iso/alp.iso"),
"{cfg}"
);
assert!(
cfg.contains("initrd (http,10.0.0.5:8080)/iso/alp/boot/initrd"),
"{cfg}"
);
assert!(cfg.contains("Boot from local disk"), "{cfg}");
}
#[test]
fn config_tries_ipxe_chainload_before_menu() {
// v0.7.1: SB-off machines recover full iPXE in the same boot;
// SB-on machines fail the chainload inline and reach the menu.
let cfg = render_grub_menu(&[linux_iso()], "http://10.0.0.5:8080");
let chain_pos = cfg
.find("if chainloader (http,10.0.0.5:8080)/ipxe/$openpxe_ipxe ; then")
.expect("chainload attempt missing");
let menu_pos = cfg.find("menuentry").expect("menu missing");
assert!(
chain_pos < menu_pos,
"chainload must precede the menu:\n{cfg}"
);
// Arch-conditional binary selection via GRUB's $grub_cpu.
assert!(cfg.contains("set openpxe_ipxe=ipxe-arm64.efi"), "{cfg}");
assert!(cfg.contains("set openpxe_ipxe=ipxe.efi"), "{cfg}");
}
#[test]
fn sanboot_and_wimboot_entries_are_omitted() {
let mut iso = linux_iso();
iso.boot_entries = vec![BootEntry {
id: "win".into(),
title: "Windows".into(),
kind: BootKind::SanBootIso {
iso_url: "iso/win.iso".into(),
},
}];
let cfg = render_grub_menu(&[iso], "http://10.0.0.5");
assert!(!cfg.contains("Windows"), "{cfg}");
assert!(cfg.contains("No Secure-Boot-bootable images"), "{cfg}");
}
}
+40 -456
View File
@@ -23,22 +23,12 @@
//! There is intentionally no UI path to upload a custom `.ipxe` script. //! There is intentionally no UI path to upload a custom `.ipxe` script.
use openpxe_core::{Settings, TimeoutAction}; use openpxe_core::{Settings, TimeoutAction};
use openpxe_iso_store::introspect::DistroFamily;
use openpxe_iso_store::{BootEntry, BootKind, IsoMeta}; use openpxe_iso_store::{BootEntry, BootKind, IsoMeta};
use openpxe_iso_store::introspect::DistroFamily;
use std::fmt::Write as _; use std::fmt::Write as _;
/// Top-level OpenPXE boot menu. Serialized identically for BIOS and UEFI /// Top-level OpenPXE boot menu. Serialized identically for BIOS and UEFI
/// clients because iPXE normalises the menu primitives across firmwares. /// clients because iPXE normalises the menu primitives across firmwares.
///
/// v0.4.69: rendered with an iVentoy-style graphical frame — a
/// `console --picture` directive paints a full-screen PNG background
/// (the operator's uploaded logo on a dark field, or the default
/// OpenPXE mark) with the menu text overlaid below a reserved top
/// margin, plus a footer carrying version + arch + firmware kind. On
/// iPXE binaries built with `IMAGE_PNG` + `CONSOLE_FRAMEBUFFER` (our
/// x86_64 UEFI binaries, compiled from source) the background paints;
/// on binaries without PNG support the `|| console` fallback yields a
/// clean text menu. The old ASCII wordmark has been removed.
#[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 {
let mut s = String::new(); let mut s = String::new();
@@ -57,127 +47,45 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
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");
// v0.4.69: graphical background. `/branding/pxe-logo` always
// returns a full-screen 1024×768 PNG now — the operator's logo on a
// dark field, or a default OpenPXE mark when none is uploaded. The
// `--top 290` reserves the top band (where the logo paints) so the
// menu text lands below it.
//
// v0.5.7: gate the whole command behind `iseq ${platform} efi`.
// `console --picture` needs IMAGE_PNG + CONSOLE_FRAMEBUFFER, which
// only our from-source UEFI binaries carry (x86_64/arm64 UEFI — see
// deploy/docker/Dockerfile). The fetched BIOS `undionly.kpxe` has
// neither, and on legacy BIOS the `--picture` attempt misbehaves
// *before* the trailing `|| console` fallback can recover (it tries
// to set a framebuffer mode the BIOS console can't honour). Guarding
// on platform means BIOS clients never issue the command at all —
// they drop straight to the plain text menu — while UEFI clients
// still get the graphical background. A PNG-less UEFI build (e.g. the
// upstream i386-efi baseline) still falls back gracefully through the
// same `|| console`. No operator toggle needed; mixed BIOS+UEFI
// fleets each get the right treatment automatically.
let _ = writeln!(
s,
"iseq ${{platform}} efi && console --picture {base}/branding/pxe-logo --top 290 || console"
);
// Map iPXE's ${{buildarch}} + ${{platform}} into the human form the
// user asked for (e.g. "x86 BIOS", "x86_64 UEFI", "arm64 UEFI").
// iPXE evaluates `iseq` lazily, so we only set whichever line
// matches. Anything not on the allowlist falls through to a generic
// `<buildarch> <platform>` display.
let _ = writeln!(s, "set arch-label ${{buildarch}} ${{platform}}");
let _ = writeln!(
s,
"iseq ${{buildarch}} i386 && iseq ${{platform}} pcbios && set arch-label x86 BIOS || iseq ${{buildarch}} x86_64 && iseq ${{platform}} efi && set arch-label x86_64 UEFI || iseq ${{buildarch}} arm64 && iseq ${{platform}} efi && set arch-label arm64 UEFI || true"
);
let _ = writeln!(s, ":menu"); let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu OpenPXE - network boot menu"); let _ = writeln!(s, "menu OpenPXE - network boot menu");
// v0.4.69: the ASCII wordmark is gone — the graphical background let _ = writeln!(s, "item --gap -- ------------------------- Default -------------------------");
// (set via `console --picture` above) carries the branding now.
let _ = writeln!(s, "item --gap");
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!( let _ = writeln!(s, "item --gap -- ----------------------- Installers -----------------------");
s,
"item --gap -- ----------------------- Installers -----------------------"
);
if has_family(isos, is_linux_family) { if has_family(isos, is_linux_family) {
let _ = writeln!(s, "item linux Linux Installers >"); let _ = writeln!(s, "item linux Linux Installers >");
} else { } else {
let _ = writeln!(s, "item --gap -- (no Linux ISOs uploaded)"); let _ = writeln!(s, "item --gap -- (no Linux ISOs uploaded)");
} }
// v0.5.8: Windows just works — no Settings toggle. Show the Windows if settings.windows_enabled && has_family(isos, is_windows_family) {
// installers submenu whenever a Windows ISO is present; entries boot
// via HTTP sanboot of the raw ISO, so no SMB/extraction is required.
if has_family(isos, is_windows_family) {
let _ = writeln!(s, "item windows Windows Installers >"); let _ = writeln!(s, "item windows Windows Installers >");
} else { } else if settings.windows_enabled {
let _ = writeln!(s, "item --gap -- (no Windows ISOs uploaded)"); let _ = writeln!(s, "item --gap -- (no Windows ISOs uploaded)");
} else {
let _ = writeln!(s, "item --gap -- (Windows support disabled in Settings)");
} }
let _ = writeln!( let _ = writeln!(s, "item --gap -- -------------------------- Tools --------------------------");
s,
"item --gap -- -------------------------- Tools --------------------------"
);
let _ = writeln!(s, "item tools Tools >"); let _ = writeln!(s, "item tools Tools >");
let _ = writeln!( let _ = writeln!(s, "item --gap -- ---------------------- Queued Deployment ---------------------");
s,
"item --gap -- ---------------------- Queued Deployment ---------------------"
);
let _ = writeln!(s, "item queue Queued Deployment (join queue)"); let _ = writeln!(s, "item queue Queued Deployment (join queue)");
let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key x exit Exit iPXE"); let _ = writeln!(s, "item --key x exit Exit iPXE");
// v0.4.6 footer line. Sits just above the `choose` line so it's
// always visible regardless of how the menu paginates. iPXE
// interpolates `${arch-label}` (set near the top of this script)
// and `${version}` is the binary-baked iPXE version — *not* the
// OpenPXE version — so we hard-code the OpenPXE version string
// here.
let openpxe_version = env!("CARGO_PKG_VERSION");
let _ = writeln!(s, "item --gap");
let _ = writeln!(
s,
"item --gap -- OpenPXE v{openpxe_version} - ${{arch-label}}"
);
if matches!(settings.timeout_action, TimeoutAction::Stay) { if matches!(settings.timeout_action, TimeoutAction::Stay) {
let _ = writeln!(s, "choose --default {default_item} target || goto menu"); let _ = writeln!(s, "choose --default {default_item} target || goto menu");
} else { } else {
let _ = writeln!( let _ = writeln!(s, "choose --default {default_item} --timeout {timeout_ms} target || goto menu");
s,
"choose --default {default_item} --timeout {timeout_ms} target || goto menu"
);
} }
// iPXE's `||` is strict about what follows. Each test uses `goto menu` // iPXE's `||` is strict about what follows. Each test uses `goto menu`
// as the fallthrough target so the parser never sees a bare `||` with // as the fallthrough target so the parser never sees a bare `||` with
// trailing whitespace — some iPXE builds reject that. // trailing whitespace — some iPXE builds reject that.
let _ = writeln!( let _ = writeln!(s, "iseq ${{target}} local && chain {base}/boot/_local.ipxe || goto menu");
s, let _ = writeln!(s, "iseq ${{target}} linux && chain {base}/boot/_linux_menu.ipxe || goto menu");
"iseq ${{target}} local && chain {base}/boot/_local.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!( let _ = writeln!(s, "iseq ${{target}} queue && chain {base}/boot/_queue.ipxe || goto menu");
s, let _ = writeln!(s, "iseq ${{target}} exit && exit || goto menu");
"iseq ${{target}} linux && chain {base}/boot/_linux_menu.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} windows && chain {base}/boot/_windows_menu.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} tools && chain {base}/boot/_tools_menu.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} queue && chain {base}/boot/_queue.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} exit && exit || goto menu"
);
let _ = writeln!(s, "goto menu"); let _ = writeln!(s, "goto menu");
s s
} }
@@ -187,51 +95,25 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
#[must_use] #[must_use]
pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) -> String { pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) -> String {
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
let title = if is_windows { let title = if is_windows { "Windows Installers" } else { "Linux Installers" };
"Windows Installers"
} else {
"Linux Installers"
};
let label = if is_windows { "windows" } else { "linux" }; let label = if is_windows { "windows" } else { "linux" };
let mut s = String::new(); let mut s = String::new();
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "set base-url {base}"); let _ = writeln!(s, "set base-url {base}");
let _ = writeln!(s, ":menu"); let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu OpenPXE - {title}"); let _ = writeln!(s, "menu OpenPXE - {title}");
let filter: fn(DistroFamily) -> bool = if is_windows { let filter: fn(DistroFamily) -> bool =
is_windows_family if is_windows { is_windows_family } else { is_linux_family };
} else {
is_linux_family
};
let mut count = 0; let mut count = 0;
for iso in isos { for iso in isos {
if !filter(iso.introspection.family) { if !filter(iso.introspection.family) { continue; }
continue;
}
// v0.4.4: ISOs the operator flipped to the Tools category move
// out of the OS installer submenus entirely — they only appear
// under Tools. Without this filter the operator would see the
// same ISO in both menus.
if matches!(iso.category, openpxe_iso_store::IsoCategory::Tools) {
continue;
}
for entry in &iso.boot_entries { for entry in &iso.boot_entries {
let size_label = fmt_size_mib(iso.size_bytes); let size_label = fmt_size_mib(iso.size_bytes);
let key = hotkey_for_index(count); let key = hotkey_for_index(count);
// Visual hint: a leading `*` marks password-protected entries.
// ASCII only — iPXE's menu console mangles non-ASCII on some
// firmwares.
let lock = if iso.is_password_protected() {
"*"
} else {
" "
};
let _ = writeln!( let _ = writeln!(
s, s, "item {}{} [{:>6}] {}",
"item {}{} {}[{:>6}] {}",
key, key,
entry.id, entry.id,
lock,
size_label, size_label,
escape_label(&entry.title), escape_label(&entry.title),
); );
@@ -244,18 +126,8 @@ pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) ->
let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key b back < Back to main menu"); let _ = writeln!(s, "item --key b back < Back to main menu");
let _ = writeln!(s, "choose target || goto menu"); let _ = writeln!(s, "choose target || goto menu");
let _ = writeln!( let _ = writeln!(s, "iseq ${{target}} back && chain {base}/boot.ipxe || goto menu");
s, let _ = writeln!(s, "chain {base}/boot/${{target}}.ipxe || goto menu");
"iseq ${{target}} back && chain {base}/boot.ipxe || goto menu"
);
// Pass `?mac=${mac}` so the per-entry handler can record the booting
// client into the Host log. iPXE substitutes `${mac}` before
// the HTTP fetch; if the firmware can't resolve it the literal
// `${mac}` is sent and the server treats it as "unknown".
let _ = writeln!(
s,
"chain {base}/boot/${{target}}.ipxe?mac=${{mac}} || goto menu"
);
s s
} }
@@ -276,52 +148,15 @@ fn hotkey_for_index(i: usize) -> String {
} }
} }
/// Tools submenu — Utilities, Shell, NIC Info, Reboot, Exit to firmware, /// Tools submenu — Utilities, Shell, NIC Info, Reboot, Exit to firmware.
/// plus any ISOs the operator flipped to [`IsoCategory::Tools`] in the
/// Storage tab. The category-Tools ISOs render first so frequently used
/// recovery / hardware tools are reachable with a single number key
/// before the built-in shortcuts.
#[must_use] #[must_use]
pub fn render_tools_menu(isos: &[IsoMeta], base_url: &str) -> String { pub fn render_tools_menu(base_url: &str) -> String {
let base = base_url.trim_end_matches('/'); let 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, "set base-url {base}"); let _ = writeln!(s, "set base-url {base}");
let _ = writeln!(s, ":menu"); let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu OpenPXE - Tools"); let _ = writeln!(s, "menu OpenPXE - Tools");
// Operator-categorized tool ISOs (hotkeys 1..9), each chained the
// same way as a per-family menu pick — through the boot-entry id
// route, carrying `?mac=${mac}` for Host log attribution.
let mut count = 0;
for iso in isos {
if !matches!(iso.category, openpxe_iso_store::IsoCategory::Tools) {
continue;
}
for entry in &iso.boot_entries {
let size_label = fmt_size_mib(iso.size_bytes);
let key = hotkey_for_index(count);
let lock = if iso.is_password_protected() {
"*"
} else {
" "
};
let _ = writeln!(
s,
"item {}{} {}[{:>6}] {}",
key,
entry.id,
lock,
size_label,
escape_label(&entry.title),
);
count += 1;
}
}
if count > 0 {
let _ = writeln!(s, "item --gap");
}
let _ = writeln!(s, "item --key u util Utilities (memtest, ...)"); let _ = writeln!(s, "item --key u util Utilities (memtest, ...)");
let _ = writeln!(s, "item --key s shell OpenPXE Shell"); let _ = writeln!(s, "item --key s shell OpenPXE Shell");
let _ = writeln!(s, "item --key n nic Network Card Info"); let _ = writeln!(s, "item --key n nic Network Card Info");
@@ -331,36 +166,13 @@ pub fn render_tools_menu(isos: &[IsoMeta], base_url: &str) -> String {
let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key b back < Back to main menu"); let _ = writeln!(s, "item --key b back < Back to main menu");
let _ = writeln!(s, "choose target || goto menu"); let _ = writeln!(s, "choose target || goto menu");
let _ = writeln!( let _ = writeln!(s, "iseq ${{target}} util && chain {base}/boot/_util.ipxe || goto menu");
s, let _ = writeln!(s, "iseq ${{target}} shell && chain {base}/boot/_shell.ipxe || goto menu");
"iseq ${{target}} util && chain {base}/boot/_util.ipxe || goto menu" let _ = writeln!(s, "iseq ${{target}} nic && chain {base}/boot/_nic.ipxe || goto menu");
); let _ = writeln!(s, "iseq ${{target}} reboot && reboot || goto menu");
let _ = writeln!( let _ = writeln!(s, "iseq ${{target}} firmware && exit 0 || goto menu");
s, let _ = writeln!(s, "iseq ${{target}} back && chain {base}/boot.ipxe || goto menu");
"iseq ${{target}} shell && chain {base}/boot/_shell.ipxe || goto menu" let _ = writeln!(s, "goto menu");
);
let _ = writeln!(
s,
"iseq ${{target}} nic && chain {base}/boot/_nic.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} reboot && reboot || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} firmware && exit 0 || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} back && chain {base}/boot.ipxe || goto menu"
);
// Fall-through for category-Tools ISO ids — same as the family
// submenu, carrying `?mac=${mac}` for the boot log.
let _ = writeln!(
s,
"chain {base}/boot/${{target}}.ipxe?mac=${{mac}} || goto menu"
);
s s
} }
@@ -373,15 +185,8 @@ pub fn render_local_hdd(base_url: &str) -> String {
let mut s = String::new(); let mut s = String::new();
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "# Boot from Local HDD - platform-sensitive"); let _ = writeln!(s, "# Boot from Local HDD - platform-sensitive");
let _ = writeln!( let _ = writeln!(s, "iseq ${{platform}} pcbios && sanboot --no-describe --drive 0x80 || ");
s, let _ = writeln!(s, "# UEFI path: fall through to the firmware's next boot entry");
"iseq ${{platform}} pcbios && sanboot --no-describe --drive 0x80 || goto uefi"
);
let _ = writeln!(s, ":uefi");
let _ = writeln!(
s,
"# UEFI path: fall through to the firmware's next boot entry"
);
let _ = writeln!(s, "exit 0"); let _ = writeln!(s, "exit 0");
let _ = writeln!(s, "# If the above exit returns, loop back to the main menu"); let _ = writeln!(s, "# If the above exit returns, loop back to the main menu");
let _ = writeln!(s, "chain {base}/boot.ipxe"); let _ = writeln!(s, "chain {base}/boot.ipxe");
@@ -402,14 +207,8 @@ pub fn render_util(base_url: &str) -> String {
let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item back < Back"); let _ = writeln!(s, "item back < Back");
let _ = writeln!(s, "choose target || goto menu"); let _ = writeln!(s, "choose target || goto menu");
let _ = writeln!( let _ = writeln!(s, "iseq ${{target}} memtest && chain {base}/ipxe/memtest.bin || ");
s, let _ = writeln!(s, "iseq ${{target}} back && chain {base}/boot/_tools_menu.ipxe || ");
"iseq ${{target}} memtest && chain {base}/ipxe/memtest.bin || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} back && chain {base}/boot/_tools_menu.ipxe || goto menu"
);
let _ = writeln!(s, "goto menu"); let _ = writeln!(s, "goto menu");
s s
} }
@@ -456,10 +255,7 @@ 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!( let _ = writeln!(s, "# Queued Deployment - join the queue and wait for operator");
s,
"# Queued Deployment - join the queue and wait for operator"
);
let _ = writeln!(s, "echo Joining deployment queue..."); let _ = writeln!(s, "echo Joining deployment queue...");
// imgfetch writes the body to a file in iPXE's transient FS; we read // imgfetch writes the body to a file in iPXE's transient FS; we read
// the queue entry id out of the Location-style header by asking the server // the queue entry id out of the Location-style header by asking the server
@@ -469,42 +265,19 @@ pub fn render_queue_entry(base_url: &str) -> String {
} }
/// Per-entry boot script (same as Phase 1, with extra_kernel_args appended). /// Per-entry boot script (same as Phase 1, with extra_kernel_args appended).
///
/// `unattended_args` (v0.5.2) carries the per-host unattended-install
/// kernel arguments (`inst.ks=…`, `auto=true … url=…`, or
/// `autoinstall ds=nocloud-net;s=…`) when the requesting MAC has a
/// deployment profile with an answer file selected. It's appended to the
/// Linux kernel command line after the operator's global extra args, and
/// ignored for Windows (wimboot) / sanboot entries which don't take a
/// kernel cmdline.
#[must_use] #[must_use]
pub fn render_entry( pub fn render_entry(entry: &BootEntry, settings: &Settings, base_url: &str) -> String {
entry: &BootEntry,
settings: &Settings,
base_url: &str,
unattended_args: Option<&str>,
) -> String {
let mut s = String::new(); let mut s = String::new();
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "set base-url {base}"); let _ = writeln!(s, "set base-url {base}");
match &entry.kind { match &entry.kind {
BootKind::LinuxKernel { BootKind::LinuxKernel { kernel_url, initrd_urls, args } => {
kernel_url,
initrd_urls,
args,
} => {
let mut cmdline = args.cmdline.replace("${base-url}", base); let mut cmdline = args.cmdline.replace("${base-url}", base);
if !settings.extra_kernel_args.trim().is_empty() { if !settings.extra_kernel_args.trim().is_empty() {
cmdline.push(' '); cmdline.push(' ');
cmdline.push_str(settings.extra_kernel_args.trim()); cmdline.push_str(settings.extra_kernel_args.trim());
} }
if let Some(extra) = unattended_args {
if !extra.trim().is_empty() {
cmdline.push(' ');
cmdline.push_str(extra.trim());
}
}
let _ = writeln!(s, "kernel {base}/{kernel_url} {cmdline}"); let _ = writeln!(s, "kernel {base}/{kernel_url} {cmdline}");
for u in initrd_urls { for u in initrd_urls {
let _ = writeln!(s, "initrd {base}/{u}"); let _ = writeln!(s, "initrd {base}/{u}");
@@ -550,194 +323,5 @@ fn has_family(isos: &[IsoMeta], pred: fn(DistroFamily) -> bool) -> bool {
} }
fn escape_label(s: &str) -> String { fn escape_label(s: &str) -> String {
s.chars() s.chars().map(|c| match c { '\n' | '\r' => ' ', c => c }).collect()
.map(|c| match c {
'\n' | '\r' => ' ',
c => c,
})
.collect()
}
/// Render the password-prompt script for a protected boot entry.
///
/// Flow on the client:
/// 1. iPXE clears any leftover ${password}, prints a banner naming the
/// ISO so the operator knows what they're being asked for.
/// 2. `read --secret password` accepts input without echoing it to
/// the screen.
/// 3. An empty input bails back to the main menu (lets the operator
/// back out of a misclick).
/// 4. Otherwise the script chains the same /boot/<id>.ipxe URL but
/// with `?token=${password:uristring}`. iPXE's `:uristring`
/// modifier URL-encodes the value so `&`, `?`, `=`, spaces, etc.
/// survive transport.
/// 5. The server replies with either the boot script (correct
/// password) or [`render_password_failed`] (wrong password). On
/// transport failure we fall back to the main menu.
#[must_use]
pub fn render_password_prompt(entry_id: &str, iso_filename: &str, base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let label = escape_label(iso_filename);
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "# OpenPXE password prompt for {label}");
let _ = writeln!(s, "echo");
let _ = writeln!(s, "echo ==========================================");
let _ = writeln!(s, "echo This image requires a password");
let _ = writeln!(s, "echo {label}");
let _ = writeln!(s, "echo (enter alone returns to main menu)");
let _ = writeln!(s, "echo ==========================================");
let _ = writeln!(s, "set password ");
let _ = writeln!(s, "read --secret password");
let _ = writeln!(
s,
"iseq ${{password}} \"\" && chain {base}/boot.ipxe || goto submit"
);
let _ = writeln!(s, ":submit");
let _ = writeln!(s, "echo Verifying...");
// Carry `mac=${mac}` alongside the token so a successful unlock
// records the actual client MAC into the Host log. On
// older iPXE that can't resolve `${mac}` the server just stores it
// as "unknown" rather than refusing to boot.
let _ = writeln!(
s,
"chain {base}/boot/{entry_id}.ipxe?token=${{password:uristring}}&mac=${{mac}} \
|| chain {base}/boot.ipxe"
);
s
}
/// Render the "wrong password" script. Tells the operator, sleeps for
/// two seconds (gives the eye time to register the message and dampens
/// brute-force rate without help from the server), and chains back to
/// the same entry — which sends them through the prompt flow again.
#[must_use]
pub fn render_password_failed(entry_id: &str, base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "echo");
let _ = writeln!(s, "echo Wrong password.");
let _ = writeln!(s, "sleep 2");
let _ = writeln!(
s,
"chain {base}/boot/{entry_id}.ipxe || chain {base}/boot.ipxe"
);
s
}
#[cfg(test)]
mod password_tests {
use super::*;
#[test]
fn prompt_uses_secret_read_and_uri_escape() {
let s = render_password_prompt("alpha-linux", "Alpha Test.iso", "http://10.0.0.5");
assert!(s.starts_with("#!ipxe\n"));
assert!(s.contains("read --secret password"));
assert!(s.contains("Alpha Test.iso"));
// URI-string modifier on the var so passwords with `&`/spaces survive.
assert!(s.contains("token=${password:uristring}"));
// Empty enter sends back to the main menu, not back into the prompt
// (avoids a wedged client if the operator chose by mistake).
assert!(s.contains("&& chain http://10.0.0.5/boot.ipxe || goto submit"));
// Never log/echo the value.
assert!(!s.contains("echo ${password"));
}
#[test]
fn failed_chains_back_to_entry() {
let s = render_password_failed("alpha-linux", "http://10.0.0.5");
assert!(s.contains("Wrong password."));
// Re-target the entry so the prompt flow runs again.
assert!(s.contains("chain http://10.0.0.5/boot/alpha-linux.ipxe"));
}
#[test]
fn top_menu_has_polished_branding_and_arch_footer() {
// v0.4.69: the menu emits a `console --picture` line that paints
// a full-screen PNG background (the operator's logo, or the
// default OpenPXE mark) reserving a top margin for it, then
// falls back to a clean text console on iPXE builds without PNG
// support. The ASCII wordmark is gone — the graphical
// background carries the branding now. A single-line footer
// still carries the OpenPXE version + arch.
let settings = Settings::default();
let s = render_menu(&[], &settings, "http://10.0.0.5");
assert!(
s.contains("console --picture http://10.0.0.5/branding/pxe-logo"),
"missing console --picture line:\n{s}"
);
// The picture call reserves a top margin for the logo band.
assert!(s.contains("--top 290"), "missing --top margin:\n{s}");
// Picture-or-text-console must be a single statement so older
// iPXE parsers don't choke on the chain.
assert!(s.contains("|| console"), "missing graceful fallback:\n{s}");
// The ASCII wordmark must be GONE — its removal is the whole
// point of v0.4.69's graphical background.
assert!(
!s.contains("___ ___ __ __ ___"),
"ASCII banner should have been removed:\n{s}"
);
// Footer with version + arch interpolation. The version comes
// from CARGO_PKG_VERSION at compile time.
let version = env!("CARGO_PKG_VERSION");
assert!(
s.contains(&format!("OpenPXE v{version}")),
"footer missing OpenPXE version:\n{s}"
);
assert!(
s.contains("${arch-label}"),
"footer missing arch-label interpolation:\n{s}"
);
// No website URL — the design brief calls that out as tacky.
assert!(
!s.to_ascii_lowercase().contains("openpxe.com"),
"footer should not advertise the website:\n{s}"
);
// Arch-label mapping covers the three labels from the brief:
// "x86 BIOS", "x86_64 UEFI", "arm64 UEFI".
assert!(s.contains("x86 BIOS"), "{s}");
assert!(s.contains("x86_64 UEFI"), "{s}");
assert!(s.contains("arm64 UEFI"), "{s}");
}
// v0.5.4: a full snapshot of the rendered top menu. The fragment
// `assert!`s above check specific invariants; this catches *any* other
// drift (a reordered item, a dropped line, changed spacing) so it's
// reviewed deliberately. The OpenPXE version is filtered out so the
// snapshot doesn't churn on every release bump.
#[test]
fn render_menu_snapshot() {
// Normalize the compile-time version so the snapshot doesn't churn
// on every release bump (no insta `filters` feature needed).
let rendered = render_menu(&[], &Settings::default(), "http://10.0.0.5").replace(
concat!("OpenPXE v", env!("CARGO_PKG_VERSION")),
"OpenPXE vX.Y.Z",
);
insta::assert_snapshot!(rendered);
}
#[test]
fn generated_scripts_do_not_emit_bare_or_trailing_fallbacks() {
let settings = Settings::default();
let scripts = [
render_menu(&[], &settings, "http://10.0.0.5"),
render_tools_menu(&[], "http://10.0.0.5"),
render_local_hdd("http://10.0.0.5"),
render_util("http://10.0.0.5"),
render_shell("http://10.0.0.5"),
render_nic_info("http://10.0.0.5"),
render_queue_entry("http://10.0.0.5"),
render_password_failed("alpha-linux", "http://10.0.0.5"),
];
for script in scripts {
for line in script.lines() {
assert!(
!line.trim_end().ends_with("||"),
"bare iPXE fallback operator in line: {line}\nscript:\n{script}"
);
}
}
}
} }
+10 -33
View File
@@ -31,9 +31,7 @@ pub fn lookup(iso_path: &Path, in_iso_path: &str) -> Option<FileLocation> {
.split('/') .split('/')
.filter(|c| !c.is_empty()) .filter(|c| !c.is_empty())
.collect(); .collect();
if components.is_empty() { if components.is_empty() { return None; }
return None;
}
walk(&mut f, root.offset, root.length, &components) walk(&mut f, root.offset, root.length, &components)
} }
@@ -42,16 +40,11 @@ fn read_root_directory(f: &mut std::fs::File) -> Option<FileLocation> {
let mut pvd = [0u8; 2048]; let mut pvd = [0u8; 2048];
f.seek(SeekFrom::Start(16 * SECTOR)).ok()?; f.seek(SeekFrom::Start(16 * SECTOR)).ok()?;
f.read_exact(&mut pvd).ok()?; f.read_exact(&mut pvd).ok()?;
if pvd[0] != 0x01 || &pvd[1..6] != b"CD001" { if pvd[0] != 0x01 || &pvd[1..6] != b"CD001" { return None; }
return None;
}
// Root directory record is at offset 156, length 34. // Root directory record is at offset 156, length 34.
let rec = &pvd[156..156 + 34]; let rec = &pvd[156..156 + 34];
let (offset, length) = parse_dir_record_ext(rec)?; let (offset, length) = parse_dir_record_ext(rec)?;
Some(FileLocation { Some(FileLocation { offset: offset * SECTOR, length })
offset: offset * SECTOR,
length,
})
} }
/// Walk components down the directory tree starting at `dir_offset`. /// Walk components down the directory tree starting at `dir_offset`.
@@ -73,29 +66,21 @@ fn walk(
if len == 0 { if len == 0 {
// Padding to sector boundary. // Padding to sector boundary.
let next = (i / SECTOR as usize + 1) * SECTOR as usize; let next = (i / SECTOR as usize + 1) * SECTOR as usize;
if next <= i { if next <= i { break; }
break;
}
i = next; i = next;
continue; continue;
} }
if i + len > dir.len() { if i + len > dir.len() { break; }
break;
}
let rec = &dir[i..i + len]; let rec = &dir[i..i + len];
let name = dir_record_name(rec); let name = dir_record_name(rec);
let is_dir = (rec.get(25).copied().unwrap_or(0) & 0x02) != 0; let is_dir = (rec.get(25).copied().unwrap_or(0) & 0x02) != 0;
// Skip "." (0x00) and ".." (0x01) pseudo-entries. // Skip "." (0x00) and ".." (0x01) pseudo-entries.
let is_pseudo = matches!(rec.get(32).copied(), Some(1)) let is_pseudo = matches!(rec.get(32).copied(), Some(1)) && rec.get(33).copied() == Some(0x00)
&& rec.get(33).copied() == Some(0x00)
|| matches!(rec.get(32).copied(), Some(1)) && rec.get(33).copied() == Some(0x01); || matches!(rec.get(32).copied(), Some(1)) && rec.get(33).copied() == Some(0x01);
if !is_pseudo && name.eq_ignore_ascii_case(target) { if !is_pseudo && name.eq_ignore_ascii_case(target) {
let (child_off, child_len) = parse_dir_record_ext(rec)?; let (child_off, child_len) = parse_dir_record_ext(rec)?;
if rest.is_empty() && !is_dir { if rest.is_empty() && !is_dir {
return Some(FileLocation { return Some(FileLocation { offset: child_off * SECTOR, length: child_len });
offset: child_off * SECTOR,
length: child_len,
});
} else if !rest.is_empty() && is_dir { } else if !rest.is_empty() && is_dir {
return walk(f, child_off * SECTOR, child_len, rest); return walk(f, child_off * SECTOR, child_len, rest);
} }
@@ -109,9 +94,7 @@ fn walk(
/// Layout per ISO9660: bytes 2..10 extent LBA (LE+BE duplicate), 10..18 /// Layout per ISO9660: bytes 2..10 extent LBA (LE+BE duplicate), 10..18
/// data length (LE+BE duplicate). We trust the little-endian copy. /// data length (LE+BE duplicate). We trust the little-endian copy.
fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> { fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> {
if rec.len() < 34 { if rec.len() < 34 { return None; }
return None;
}
let lba = u32::from_le_bytes(rec[2..6].try_into().ok()?) as u64; let lba = u32::from_le_bytes(rec[2..6].try_into().ok()?) as u64;
let len = u32::from_le_bytes(rec[10..14].try_into().ok()?) as u64; let len = u32::from_le_bytes(rec[10..14].try_into().ok()?) as u64;
Some((lba, len)) Some((lba, len))
@@ -121,15 +104,9 @@ fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> {
/// `;1` version suffix. /// `;1` version suffix.
fn dir_record_name(rec: &[u8]) -> String { fn dir_record_name(rec: &[u8]) -> String {
let name_len = *rec.get(32).unwrap_or(&0) as usize; let name_len = *rec.get(32).unwrap_or(&0) as usize;
if name_len == 0 || rec.len() < 33 + name_len { if name_len == 0 || rec.len() < 33 + name_len { return String::new(); }
return String::new();
}
let raw = &rec[33..33 + name_len]; let raw = &rec[33..33 + name_len];
let s = String::from_utf8_lossy(raw).to_string(); let s = String::from_utf8_lossy(raw).to_string();
// Strip `;N` version suffix. // Strip `;N` version suffix.
if let Some(i) = s.rfind(';') { if let Some(i) = s.rfind(';') { s[..i].to_string() } else { s }
s[..i].to_string()
} else {
s
}
} }
-6
View File
@@ -14,17 +14,11 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
pub mod app; pub mod app;
pub mod auth;
pub mod error;
pub mod grub_script;
pub mod ipxe_script; pub mod ipxe_script;
pub mod iso_fs; pub mod iso_fs;
pub mod log_stream; pub mod log_stream;
pub mod notify;
pub mod saml_routes;
pub mod state; pub mod state;
pub mod terminal; pub mod terminal;
pub mod uploads;
pub use app::build_router; pub use app::build_router;
pub use state::AppState; pub use state::AppState;
+6 -10
View File
@@ -36,11 +36,9 @@ pub async fn stream(
let rx = state.log_bus.subscribe(); let rx = state.log_bus.subscribe();
let live = BroadcastStream::new(rx).map(|res| match res { let live = BroadcastStream::new(rx).map(|res| match res {
Ok(line) => Ok(Event::default().data(line_json(&line))), Ok(line) => Ok(Event::default().data(line_json(&line))),
Err(tokio_stream::wrappers::errors::BroadcastStreamRecvError::Lagged(n)) => { Err(tokio_stream::wrappers::errors::BroadcastStreamRecvError::Lagged(n)) => Ok(Event::default()
Ok(Event::default() .event("lagged")
.event("lagged") .data(json!({ "skipped": n }).to_string())),
.data(json!({ "skipped": n }).to_string()))
}
}); });
Sse::new(recent_stream.chain(live)) Sse::new(recent_stream.chain(live))
@@ -57,11 +55,9 @@ pub async fn recent(State(state): State<AppState>) -> Json<serde_json::Value> {
/// keep streaming new lines as they arrive). /// keep streaming new lines as they arrive).
pub async fn clear(State(state): State<AppState>) -> Json<serde_json::Value> { pub async fn clear(State(state): State<AppState>) -> Json<serde_json::Value> {
state.log_bus.clear(); state.log_bus.clear();
state.log_bus.push( state
"info", .log_bus
"openpxe::terminal", .push("info", "openpxe::terminal", "log buffer cleared by operator");
"log buffer cleared by operator",
);
Json(json!({ "ok": true })) Json(json!({ "ok": true }))
} }
-142
View File
@@ -1,142 +0,0 @@
//! Notification *delivery* — the network half of the notify feature.
//!
//! `openpxe_core::notify` owns the config + persistence; this module
//! turns a `NotifyConfig` + a message into an actual delivery:
//!
//! - Slack / Discord / Teams → HTTP POST of a provider-shaped JSON
//! body to the operator's incoming-webhook URL (via `reqwest`).
//! - SMTP → a TLS email via `lettre`.
//!
//! Every send is best-effort and time-bounded: a flaky webhook must
//! never wedge a PXE boot. Callers fire these from a detached task.
use openpxe_core::{NotifyConfig, NotifyKind};
use std::time::Duration;
/// Hard ceiling on any single delivery so a hung endpoint can't pin a
/// task forever.
const SEND_TIMEOUT: Duration = Duration::from_secs(10);
/// Deliver `body` (with an optional `subject`, used as the email
/// subject / chat bold-line) using the active provider in `cfg`.
/// Returns `Ok(())` on success, or a human-readable error suitable for
/// surfacing in the "Send test" response.
pub async fn send(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(), String> {
if !cfg.is_usable() {
return Err("notifications are not enabled / fully configured".into());
}
match cfg.kind {
NotifyKind::Slack | NotifyKind::Discord | NotifyKind::Teams => {
send_webhook(cfg, subject, body).await
}
NotifyKind::Smtp => send_email(cfg, subject, body).await,
}
}
async fn send_webhook(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(), String> {
// Each chat platform wants a different JSON shape for an incoming
// webhook. Keep the bodies minimal and plain-text-ish so they
// render cleanly everywhere.
let combined = if subject.is_empty() {
body.to_string()
} else {
format!("*{subject}*\n{body}")
};
let payload = match cfg.kind {
NotifyKind::Slack => serde_json::json!({ "text": combined }),
NotifyKind::Discord => serde_json::json!({ "content": combined }),
NotifyKind::Teams => serde_json::json!({
// Legacy MessageCard — the format every Teams "Incoming
// Webhook" connector still accepts.
"@type": "MessageCard",
"@context": "https://schema.org/extensions",
"summary": if subject.is_empty() { "OpenPXE" } else { subject },
"title": subject,
"text": body,
}),
NotifyKind::Smtp => unreachable!("smtp handled separately"),
};
let client = reqwest::Client::builder()
.timeout(SEND_TIMEOUT)
.build()
.map_err(|e| format!("could not build HTTP client: {e}"))?;
let resp = client
.post(&cfg.webhook_url)
.json(&payload)
.send()
.await
.map_err(|e| format!("webhook POST failed: {e}"))?;
let status = resp.status();
if status.is_success() {
Ok(())
} else {
let snippet = resp
.text()
.await
.unwrap_or_default()
.chars()
.take(200)
.collect::<String>();
Err(format!("webhook returned HTTP {status}: {snippet}"))
}
}
async fn send_email(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(), String> {
use lettre::transport::smtp::authentication::Credentials;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
let from = if cfg.smtp_from.trim().is_empty() {
cfg.smtp_username.trim()
} else {
cfg.smtp_from.trim()
};
if from.is_empty() {
return Err("SMTP requires a From address (or a username to fall back to)".into());
}
let email = Message::builder()
.from(
from.parse()
.map_err(|e| format!("invalid From address '{from}': {e}"))?,
)
.to(cfg
.smtp_to
.trim()
.parse()
.map_err(|e| format!("invalid To address '{}': {e}", cfg.smtp_to))?)
.subject(if subject.is_empty() {
"OpenPXE"
} else {
subject
})
.body(body.to_string())
.map_err(|e| format!("could not build email: {e}"))?;
// Implicit TLS (465) vs STARTTLS (587). We never send plaintext.
let mut builder = if cfg.smtp_implicit_tls {
AsyncSmtpTransport::<Tokio1Executor>::relay(&cfg.smtp_host)
.map_err(|e| format!("SMTP relay setup failed: {e}"))?
} else {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.smtp_host)
.map_err(|e| format!("SMTP STARTTLS setup failed: {e}"))?
}
.port(cfg.smtp_port)
.timeout(Some(SEND_TIMEOUT));
// Auth is optional — some internal relays accept unauthenticated
// mail from trusted hosts. Only attach credentials when a username
// is set.
if !cfg.smtp_username.trim().is_empty() {
builder = builder.credentials(Credentials::new(
cfg.smtp_username.trim().to_string(),
cfg.smtp_password.clone(),
));
}
let mailer = builder.build();
mailer
.send(email)
.await
.map(|_| ())
.map_err(|e| format!("SMTP send failed: {e}"))
}
-370
View File
@@ -1,370 +0,0 @@
//! SAML 2.0 Service Provider HTTP endpoints (v0.5.1).
//!
//! * `GET /api/sso/login` — SP-initiated: build an AuthnRequest, record its
//! ID, and 302 the browser to the IdP.
//! * `POST /api/sso/acs` — Assertion Consumer Service: verify + validate
//! the IdP's `SAMLResponse`, perform the stateful checks (InResponseTo
//! correlation, IdP-initiated gating, assertion replay), mint an operator
//! session, and 302 to the dashboard. (Mirrors FleetDM's `/sso/callback`.)
//! * `GET /api/sso/metadata` — serve our SP metadata XML for IdP import.
//!
//! Stateless crypto + semantic validation live in `openpxe_core::saml`; this
//! module owns only the HTTP glue and the in-memory state the SP needs.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration as StdDuration, Instant};
use axum::{
body::Body,
extract::{Form, Query, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
};
use base64::Engine;
use parking_lot::Mutex;
use serde::Deserialize;
use time::{Duration, OffsetDateTime};
use openpxe_core::saml::{self, metadata::IdpMetadata, SamlError, SpParams};
use openpxe_core::SsoConfig;
use crate::auth;
use crate::state::AppState;
/// Outstanding AuthnRequest IDs live at most this long before a matching
/// response is considered stale (covers a slow human at the IdP login form).
const REQUEST_TTL: StdDuration = StdDuration::from_mins(10);
/// How long we fetch-cache IdP metadata loaded from a URL.
const METADATA_FETCH_TIMEOUT: StdDuration = StdDuration::from_secs(10);
/// In-memory SAML runtime state. Cheap to clone (Arc-shared).
#[derive(Clone, Default)]
pub struct SamlRuntime {
/// request_id → issued_at. Correlates a response's `InResponseTo` to a
/// request *we* actually sent (replay / CSRF defense for SP-initiated).
outstanding: Arc<Mutex<HashMap<String, Instant>>>,
/// assertion_id → expiry. A consumed assertion may not be replayed.
consumed: Arc<Mutex<HashMap<String, Instant>>>,
/// Cache of IdP metadata fetched from a URL: (url, parsed).
metadata_cache: Arc<Mutex<Option<(String, IdpMetadata)>>>,
}
impl SamlRuntime {
/// Record an AuthnRequest we just sent.
pub fn register_request(&self, id: &str) {
let mut g = self.outstanding.lock();
prune(&mut g);
g.insert(id.to_owned(), Instant::now());
}
/// Consume an outstanding request ID, returning `true` if it was present
/// and still fresh. A miss means the response doesn't correlate to any
/// live request we issued.
pub fn take_request(&self, id: &str) -> bool {
let mut g = self.outstanding.lock();
prune(&mut g);
g.remove(id).is_some()
}
/// Record a consumed assertion. Returns `false` if it was already
/// consumed (a replay) — in which case the caller must reject.
pub fn record_assertion(&self, id: &str, expiry: OffsetDateTime) -> bool {
let mut g = self.consumed.lock();
prune(&mut g);
if g.contains_key(id) {
return false;
}
let ttl = (expiry - OffsetDateTime::now_utc())
.max(Duration::ZERO)
.unsigned_abs();
g.insert(id.to_owned(), Instant::now() + ttl);
true
}
fn cached_metadata(&self, url: &str) -> Option<IdpMetadata> {
let g = self.metadata_cache.lock();
match &*g {
Some((cached_url, md)) if cached_url == url => Some(md.clone()),
_ => None,
}
}
fn cache_metadata(&self, url: String, md: IdpMetadata) {
*self.metadata_cache.lock() = Some((url, md));
}
}
/// Drop expired entries so neither map grows unbounded.
fn prune(map: &mut HashMap<String, Instant>) {
let now = Instant::now();
// For the request map this over-prunes (entries store issued_at, not
// expiry), so cap by REQUEST_TTL; the consumed map stores absolute
// expiry instants. Using saturating logic keeps both correct: request
// entries older than REQUEST_TTL go, consumed entries past expiry go.
map.retain(|_, &mut t| now.saturating_duration_since(t) < REQUEST_TTL || t > now);
}
// ─── GET /api/sso/login ───────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct LoginQuery {
/// Optional local path to return to after login (becomes RelayState).
#[serde(default)]
pub next: Option<String>,
}
pub async fn sso_login(State(state): State<AppState>, Query(q): Query<LoginQuery>) -> Response {
let cfg = state.sso.snapshot();
if !cfg.is_usable() {
return redirect("/?sso_error=unavailable");
}
let idp = match resolve_idp_metadata(&state, &cfg).await {
Ok(m) => m,
Err(e) => {
tracing::warn!(target: "openpxe::saml", "sso_login: metadata unavailable: {e}");
return redirect("/?sso_error=metadata");
}
};
let Some(dest) = idp.sso_destination().map(str::to_owned) else {
tracing::warn!(target: "openpxe::saml", "sso_login: IdP metadata has no SSO endpoint");
return redirect("/?sso_error=metadata");
};
let sp = sp_params(&state, &cfg);
let relay = safe_local_path(q.next.as_deref());
match saml::authn_request::build(&sp, &dest, Some(&relay)) {
Ok(req) => {
state.saml.register_request(&req.id);
redirect(&req.location)
}
Err(e) => {
tracing::warn!(target: "openpxe::saml", "sso_login: build AuthnRequest failed: {e}");
redirect("/?sso_error=request")
}
}
}
// ─── POST /api/sso/acs ──────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct AcsForm {
#[serde(rename = "SAMLResponse")]
pub saml_response: String,
#[serde(rename = "RelayState", default)]
pub relay_state: Option<String>,
}
pub async fn sso_acs(State(state): State<AppState>, Form(form): Form<AcsForm>) -> Response {
let cfg = state.sso.snapshot();
if !cfg.is_usable() {
return redirect("/?sso_error=unavailable");
}
let xml = match base64::engine::general_purpose::STANDARD.decode(form.saml_response.as_bytes())
{
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
Err(e) => {
tracing::warn!(target: "openpxe::saml", "acs: base64 decode failed: {e}");
return redirect("/?sso_error=1");
}
};
let idp = match resolve_idp_metadata(&state, &cfg).await {
Ok(m) => m,
Err(e) => {
tracing::warn!(target: "openpxe::saml", "acs: metadata unavailable: {e}");
return redirect("/?sso_error=metadata");
}
};
let sp = sp_params(&state, &cfg);
// Signature verification + semantic checks are CPU-bound — keep them off
// the async executor.
let now = OffsetDateTime::now_utc();
let skew = Duration::seconds(saml::DEFAULT_CLOCK_SKEW_SECS);
let verify = {
let xml = xml.clone();
let sp = sp.clone();
tokio::task::spawn_blocking(move || saml::response::consume(&xml, &sp, &idp, now, skew))
.await
};
let verified = match verify {
Ok(Ok(v)) => v,
Ok(Err(e)) => {
// Never leak which specific check failed to the browser.
tracing::warn!(target: "openpxe::saml", "acs: response rejected: {e}");
return redirect("/?sso_error=1");
}
Err(join) => {
tracing::error!(target: "openpxe::saml", "acs: verify task panicked: {join}");
return redirect("/?sso_error=1");
}
};
// Stateful checks the core deliberately left to us.
match &verified.in_response_to {
Some(id) => {
if !state.saml.take_request(id) {
tracing::warn!(target: "openpxe::saml", "acs: InResponseTo matches no live request");
return redirect("/?sso_error=1");
}
}
None => {
if !cfg.allow_idp_initiated {
tracing::warn!(target: "openpxe::saml", "acs: IdP-initiated login is disabled");
return redirect("/?sso_error=idp_initiated");
}
}
}
if !state
.saml
.record_assertion(&verified.assertion_id, verified.assertion_expiry)
{
tracing::warn!(target: "openpxe::saml", "acs: assertion replay rejected");
return redirect("/?sso_error=1");
}
// Success → mint an operator session keyed to the verified email.
let session = state.sessions.create(&verified.principal.email);
tracing::info!(
target: "openpxe::saml",
email = %verified.principal.email,
idp_initiated = verified.in_response_to.is_none(),
"SAML SSO sign-in"
);
// safe_local_path already maps None / unsafe values to "/".
let relay = safe_local_path(form.relay_state.as_deref());
redirect_with_session(&relay, &session)
}
// ─── GET /api/sso/metadata ──────────────────────────────────────────────────
pub async fn sso_metadata(State(state): State<AppState>) -> Response {
let cfg = state.sso.snapshot();
let sp = sp_params(&state, &cfg);
let xml = saml::metadata::build_sp_metadata(&sp);
(
StatusCode::OK,
[(header::CONTENT_TYPE, "application/samlmetadata+xml")],
xml,
)
.into_response()
}
// ─── helpers ────────────────────────────────────────────────────────────────
/// Derive runtime SP parameters from config + the advertised public base URL.
fn sp_params(state: &AppState, cfg: &SsoConfig) -> SpParams {
let base = state.public_base_url.trim_end_matches('/');
let entity_id = if cfg.entity_id.trim().is_empty() {
base.to_owned()
} else {
cfg.entity_id.trim().to_owned()
};
SpParams {
entity_id,
acs_url: format!("{base}/api/sso/acs"),
}
}
/// Resolve the IdP metadata: prefer the metadata URL (fetched + cached) per
/// the "URL wins" rule, else parse the pasted XML.
async fn resolve_idp_metadata(state: &AppState, cfg: &SsoConfig) -> Result<IdpMetadata, SamlError> {
let url = cfg.metadata_url.trim();
if !url.is_empty() {
if let Some(md) = state.saml.cached_metadata(url) {
return Ok(md);
}
let body = fetch_metadata(url).await?;
let md = IdpMetadata::parse(&body)?;
state.saml.cache_metadata(url.to_owned(), md.clone());
return Ok(md);
}
if !cfg.metadata.trim().is_empty() {
return IdpMetadata::parse(&cfg.metadata);
}
Err(SamlError::Metadata("no metadata source configured".into()))
}
async fn fetch_metadata(url: &str) -> Result<String, SamlError> {
let client = reqwest::Client::builder()
.timeout(METADATA_FETCH_TIMEOUT)
.build()
.map_err(|e| SamlError::Metadata(format!("http client: {e}")))?;
let resp = client
.get(url)
.send()
.await
.map_err(|e| SamlError::Metadata(format!("fetch {url}: {e}")))?;
if !resp.status().is_success() {
return Err(SamlError::Metadata(format!(
"fetch {url}: HTTP {}",
resp.status()
)));
}
resp.text()
.await
.map_err(|e| SamlError::Metadata(format!("read {url}: {e}")))
}
/// Only permit a same-site path (single leading slash) as a redirect target —
/// blocks open-redirect / protocol-relative (`//evil.com`) abuse of RelayState.
fn safe_local_path(p: Option<&str>) -> String {
match p {
Some(p) if p.starts_with('/') && !p.starts_with("//") => p.to_owned(),
_ => "/".to_owned(),
}
}
fn redirect(location: &str) -> Response {
Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, location)
.body(Body::empty())
.map_or_else(
|_| StatusCode::INTERNAL_SERVER_ERROR.into_response(),
IntoResponse::into_response,
)
}
fn redirect_with_session(location: &str, session: &str) -> Response {
Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, location)
.header(header::SET_COOKIE, auth::session_cookie(session))
.body(Body::empty())
.map_or_else(
|_| StatusCode::INTERNAL_SERVER_ERROR.into_response(),
IntoResponse::into_response,
)
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
// v0.5.4: exercise the SAML metadata-URL fetch against a mock server —
// previously this path did a real network GET and had no coverage.
#[tokio::test]
async fn fetch_metadata_returns_body_on_200() {
let server = MockServer::start().await;
let xml = "<EntityDescriptor>idp</EntityDescriptor>";
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_string(xml))
.mount(&server)
.await;
let got = fetch_metadata(&server.uri()).await.expect("fetch ok");
assert_eq!(got, xml);
}
#[tokio::test]
async fn fetch_metadata_errors_on_non_2xx() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let err = fetch_metadata(&server.uri()).await.unwrap_err();
assert!(matches!(err, SamlError::Metadata(_)), "got {err:?}");
}
}
@@ -1,36 +0,0 @@
---
source: crates/http-api/src/ipxe_script.rs
expression: rendered
---
#!ipxe
# OpenPXE top-level menu - auto-generated, do not edit
set base-url http://10.0.0.5
set esc:hex 1b
set cls ${esc:string}[2J
iseq ${platform} efi && console --picture http://10.0.0.5/branding/pxe-logo --top 290 || console
set arch-label ${buildarch} ${platform}
iseq ${buildarch} i386 && iseq ${platform} pcbios && set arch-label x86 BIOS || iseq ${buildarch} x86_64 && iseq ${platform} efi && set arch-label x86_64 UEFI || iseq ${buildarch} arm64 && iseq ${platform} efi && set arch-label arm64 UEFI || true
:menu
menu OpenPXE - network boot menu
item --gap
item --gap -- ------------------------- Default -------------------------
item local Boot from Local HDD
item --gap -- ----------------------- Installers -----------------------
item --gap -- (no Linux ISOs uploaded)
item --gap -- (no Windows ISOs uploaded)
item --gap -- -------------------------- Tools --------------------------
item tools Tools >
item --gap -- ---------------------- Queued Deployment ---------------------
item queue Queued Deployment (join queue)
item --gap
item --key x exit Exit iPXE
item --gap
item --gap -- OpenPXE vX.Y.Z - ${arch-label}
choose --default queue --timeout 600000 target || goto menu
iseq ${target} local && chain http://10.0.0.5/boot/_local.ipxe || goto menu
iseq ${target} linux && chain http://10.0.0.5/boot/_linux_menu.ipxe || goto menu
iseq ${target} windows && chain http://10.0.0.5/boot/_windows_menu.ipxe || goto menu
iseq ${target} tools && chain http://10.0.0.5/boot/_tools_menu.ipxe || goto menu
iseq ${target} queue && chain http://10.0.0.5/boot/_queue.ipxe || goto menu
iseq ${target} exit && exit || goto menu
goto menu
+7 -89
View File
@@ -1,20 +1,8 @@
use crate::auth::SessionStore; use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore};
use crate::saml_routes::SamlRuntime; use openpxe_iso_store::{IsoStore, NfsManager, SmbManager};
use crate::uploads::UploadSessions;
use openpxe_core::{
AdminStore, BootLog, BootRulesStore, BootTokens, BrandingStore, ClientRegistry,
DeploymentQueue, HostBindings, LogBus, Metrics, NotifyStore, SettingsStore, SsoStore,
};
use openpxe_iso_store::{
IsoStore, NfsShareManager, SftpShareManager, SmbManager, SmbShareManager, UnattendedStore,
};
use std::sync::Arc; use std::sync::Arc;
use time::OffsetDateTime; use time::OffsetDateTime;
/// Cached composited PXE boot-menu background: `(logo_rev, encoded PNG)`.
/// See `AppState::pxe_bg_cache`.
pub type PxeBgCache = Arc<parking_lot::Mutex<Option<(u64, bytes::Bytes)>>>;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub iso_store: IsoStore, pub iso_store: IsoStore,
@@ -25,47 +13,6 @@ pub struct AppState {
/// these MACs requests `/boot.ipxe`, we chain straight to the /// these MACs requests `/boot.ipxe`, we chain straight to the
/// configured target instead of rendering the menu. /// configured target instead of rendering the menu.
pub hosts: HostBindings, pub hosts: HostBindings,
/// Persistent boot-event log surfaced under the Hosts tab. Records
/// every `/boot/<entry>.ipxe` chain that goes on to serve a script
/// (i.e. an image actually starting to install on a machine).
pub boot_log: BootLog,
/// v0.7.0: ordered label-based boot rules (MAC prefix / arch →
/// target) plus the optional boot-decision webhook. Consulted by the
/// top-level boot script after exact host bindings, before the menu.
pub boot_rules: BootRulesStore,
/// v0.7.0: short-lived access tokens for unattended answer files.
/// Minted into every generated answer-file URL; the serving endpoint
/// requires one (or an operator session) once an admin exists.
pub boot_tokens: BootTokens,
/// Operator-controlled UI overrides (custom logo). When the
/// operator hasn't uploaded anything, the WebUI serves the bundled
/// rainbow-horizon mark.
pub branding: BrandingStore,
/// v0.6.2: cache of the composited PXE boot-menu background PNG,
/// keyed on the branding logo revision. Composing costs ~50-200 ms
/// of image decode/encode and **every** booting client fetches it
/// for `console --picture` — caching makes that one compose per
/// logo change instead of one per boot.
pub pxe_bg_cache: PxeBgCache,
/// Forms-auth admin record + first-run bootstrap state. When
/// `admin.is_configured() == false`, the auth middleware passes
/// every request through and `/api/me` reports `setup_required`.
pub admin: AdminStore,
/// In-memory session table for active operator logins. Cleared on
/// process restart (sessions are tied to UI state, not persisted —
/// matches Sonarr/Radarr behaviour).
pub sessions: SessionStore,
/// SAML SSO configuration (persisted IdP metadata, Entity ID, toggles).
pub sso: SsoStore,
/// v0.5.1: in-memory SAML runtime state — outstanding AuthnRequest IDs
/// (for InResponseTo correlation), consumed-assertion replay guard, and
/// a cache of fetched IdP metadata. Tied to process lifetime, like
/// `sessions`; a restart simply invalidates any in-flight SSO login.
pub saml: SamlRuntime,
/// v0.5.0: webhook / email notification config (Slack/Teams/Discord/
/// SMTP). Drives the fire-and-forget pings on boot events and powers
/// the Advanced tab's config + "Send test" button.
pub notify: NotifyStore,
/// Lock-free metrics counters surfaced at `/metrics` in Prometheus /// Lock-free metrics counters surfaced at `/metrics` in Prometheus
/// text format. Cheap to clone (handles to atomics). /// text format. Cheap to clone (handles to atomics).
pub metrics: Metrics, pub metrics: Metrics,
@@ -73,36 +20,11 @@ pub struct AppState {
/// `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.
pub smb: Option<Arc<SmbManager>>, pub smb: Option<Arc<SmbManager>>,
/// v0.4.65: SMB share manager — userspace consumer of remote SMB /// NFS share manager. Always present (mounting is opt-in by the
/// shares via Samba's `smbclient` CLI. Replaces the kernel-mount /// operator from the Storage tab); `add()` requires `mount.nfs` to be
/// NFS path that v0.4.64 shipped; that path didn't work on hosts /// available in the runtime image. Surfaces errors per-mount rather
/// (Unraid, etc.) whose kernel ships without the nfs/cifs client /// than failing the global state.
/// modules, and no container-side configuration could fix it. pub nfs: NfsManager,
/// `smbclient` does the SMB protocol over a plain TCP socket in
/// userspace — works in any container, no special caps required.
pub smb_shares: SmbShareManager,
/// v0.4.67: NFSv3 share manager — pure-Rust userspace consumer
/// via the `nfs3_client` crate. Ships alongside the SMB manager
/// so operators pick whichever protocol their NAS prefers.
/// In-process (no subprocess); supports HTTP Range requests on
/// NFS-sourced ISOs because NFSv3 READ3 takes an explicit offset.
pub nfs_shares: NfsShareManager,
/// v0.5.5: SFTP-over-SSH share manager — pure-Rust userspace
/// consumer via `russh` + `russh-sftp` (ring backend, no OpenSSL).
/// Ships alongside SMB/NFS as the third remote-library protocol.
/// In-process (no subprocess, no kernel mount); supports HTTP Range
/// requests because SFTP opens a seekable file handle. Authenticates
/// the server's SSH host key on a trust-on-first-use basis.
pub sftp_shares: SftpShareManager,
/// v0.5.2: uploaded unattended-install answer files (Kickstart /
/// Preseed / Autoinstall / Windows answer files). Served on demand to
/// booting clients with per-host hostname/IP/MAC templating; lives in
/// its own directory, never the ISO listing or PXE menu.
pub unattended: UnattendedStore,
/// Browser chunked upload state. Multipart uploads still go straight
/// through `IsoStore`, but the UI uses sessions so large ISO transfers
/// can show deterministic progress and leave visible partial files.
pub uploads: UploadSessions,
/// Live log bus consumed by the Terminal tab via SSE. Operator-issued /// Live log bus consumed by the Terminal tab via SSE. Operator-issued
/// terminal commands also push synthetic lines onto it so the tail /// terminal commands also push synthetic lines onto it so the tail
/// shows them inline. /// shows them inline.
@@ -116,10 +38,6 @@ pub struct AppState {
/// `enp1s0`). Surfaced read-only on the Network tab. Empty if the /// `enp1s0`). Surfaced read-only on the Network tab. Empty if the
/// interface couldn't be identified. /// interface couldn't be identified.
pub nic_name: String, pub nic_name: String,
/// v0.7.2: physical link summary for that NIC (operstate, speed,
/// duplex, port MAC) — read from sysfs at startup; empty where
/// unavailable. Helps confirm which port answers PXE.
pub nic_link: String,
/// Subnet mask of the public interface in dotted-quad form. /// Subnet mask of the public interface in dotted-quad form.
pub subnet_mask: String, pub subnet_mask: String,
/// Default gateway IPv4 address. /// Default gateway IPv4 address.
+98 -365
View File
@@ -31,17 +31,12 @@ pub async fn run_command(
) -> impl IntoResponse { ) -> impl IntoResponse {
let line = req.command.trim(); let line = req.command.trim();
if line.is_empty() { if line.is_empty() {
return ( return (StatusCode::OK, Json(json!({ "output": HELP_TEXT, "ok": true })));
StatusCode::OK,
Json(json!({ "output": HELP_TEXT, "ok": true })),
);
} }
// Echo the typed command into the live log so the Terminal tab shows // Echo the typed command into the live log so the Terminal tab shows
// operator activity in-band with server-emitted log lines. // operator activity in-band with server-emitted log lines.
state state.log_bus.push("info", "openpxe::terminal", format!("> {line}"));
.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,11 +55,7 @@ pub async fn run_command(
// so reading the live tail tells the same story as scrolling the // so reading the live tail tells the same story as scrolling the
// terminal pane. // terminal pane.
let mirror = if output.len() > 1024 { let mirror = if output.len() > 1024 {
format!( format!("{}\n... ({} bytes truncated)", &output[..1024], output.len() - 1024)
"{}\n... ({} bytes truncated)",
&output[..1024],
output.len() - 1024
)
} else { } else {
output.clone() output.clone()
}; };
@@ -87,17 +78,9 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
"status" => Ok(status_text(state)), "status" => Ok(status_text(state)),
"isos" | "images" => Ok(isos_text(state)), "isos" | "images" => Ok(isos_text(state)),
"clients" => Ok(clients_text(state)), "clients" => Ok(clients_text(state)),
"queue" => queue_command(state, tail).await, "queue" => gate_command(state, tail).await,
// `smb` controls the outbound Samba server for Windows "nfs" => nfs_command(state, tail).await,
// install media. `share` lists/manages remote SMB shares
// OpenPXE pulls ISOs from (v0.4.65). `nfs` is the parallel
// command for remote NFSv3 shares (v0.4.67, in-process via
// nfs3_client — not the v0.4.64 kernel-mount path).
"share" | "smb-share" => smb_share_command(state, tail).await,
"smb" => smb_command(state, tail).await, "smb" => smb_command(state, tail).await,
"nfs" => nfs_share_command(state, tail).await,
// v0.5.5: SFTP-over-SSH remote shares (in-process russh client).
"sftp" => sftp_share_command(state, tail).await,
"log" => log_command(state, tail), "log" => log_command(state, tail),
"whoami" => Ok("operator".to_string()), "whoami" => Ok("operator".to_string()),
"echo" => Ok(tail.join(" ")), "echo" => Ok(tail.join(" ")),
@@ -113,62 +96,32 @@ 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 queue_entries = s.queue.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 smb_shares = s.smb_shares.list(); let nfs = s.nfs.list();
let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count(); let nfs_active = nfs.iter().filter(|m| m.mounted).count();
// v0.4.67: NFSv3 sources too.
let nfs_shares = s.nfs_shares.list();
let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count();
// v0.5.5: SFTP-over-SSH sources too.
let sftp_shares = s.sftp_shares.list();
let sftp_reachable = sftp_shares.iter().filter(|m| m.reachable).count();
format!( format!(
"OpenPXE {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}, smb: {n_smb}, nfs: {n_nfs}, sftp: {n_sftp})\n\ isos: {n_isos} (local: {n_local}, nfs: {n_nfs})\n\
clients: {n_clients}\n\ clients: {n_clients}\n\
queue: {n_entries}\n\ queue: {n_entries}\n\
smb server: {smb}\n\ smb: {smb}\n\
smb shares: {n_smb_total} configured ({n_smb_active} reachable)\n\ nfs mounts: {n_total} configured ({n_active} active)\n",
nfs shares: {n_nfs_total} configured ({n_nfs_active} reachable)\n\
sftp shares: {n_sftp_total} configured ({n_sftp_active} reachable)\n",
ver = env!("CARGO_PKG_VERSION"), ver = env!("CARGO_PKG_VERSION"),
base = s.public_base_url, base = s.public_base_url,
nic = if s.nic_name.is_empty() { nic = if s.nic_name.is_empty() { "?" } else { s.nic_name.as_str() },
"?"
} 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 n_local = isos.iter().filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Local)).count(),
.iter() n_nfs = isos.iter().filter(|i| !matches!(i.source, openpxe_iso_store::IsoSource::Local)).count(),
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Local))
.count(),
n_smb = isos
.iter()
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Smb { .. }))
.count(),
n_nfs = isos
.iter()
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Nfs { .. }))
.count(),
n_sftp = isos
.iter()
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Sftp { .. }))
.count(),
n_clients = clients.len(), n_clients = clients.len(),
n_entries = queue_entries.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_smb_total = smb_shares.len(), n_total = nfs.len(),
n_smb_active = smb_reachable, n_active = nfs_active,
n_nfs_total = nfs_shares.len(),
n_nfs_active = nfs_reachable,
n_sftp_total = sftp_shares.len(),
n_sftp_active = sftp_reachable,
) )
} }
@@ -186,11 +139,7 @@ fn isos_text(s: &AppState) -> String {
for i in isos { for i in isos {
let src = match i.source { let src = match i.source {
openpxe_iso_store::IsoSource::Local => "local".to_string(), openpxe_iso_store::IsoSource::Local => "local".to_string(),
openpxe_iso_store::IsoSource::Smb { share_id, .. } => format!("smb:{share_id}"), openpxe_iso_store::IsoSource::Nfs { mount_id, .. } => format!("nfs:{mount_id}"),
// v0.4.67: NFSv3 via in-process nfs3_client.
openpxe_iso_store::IsoSource::Nfs { share_id, .. } => format!("nfs:{share_id}"),
// v0.5.5: SFTP-over-SSH via in-process russh.
openpxe_iso_store::IsoSource::Sftp { share_id, .. } => format!("sftp:{share_id}"),
}; };
let _ = writeln!( let _ = writeln!(
out, out,
@@ -210,7 +159,11 @@ fn clients_text(s: &AppState) -> String {
return "(no clients yet)".into(); return "(no clients yet)".into();
} }
let mut out = String::new(); let mut out = String::new();
let _ = writeln!(out, "{:<19} {:<16} {:<8} LAST SEEN", "MAC", "IP", "EVENTS"); let _ = writeln!(
out,
"{:<19} {:<16} {:<8} LAST SEEN",
"MAC", "IP", "EVENTS"
);
for c in clients { for c in clients {
let ip = c.last_ip.map_or_else(|| "-".into(), |i| i.to_string()); let ip = c.last_ip.map_or_else(|| "-".into(), |i| i.to_string());
let _ = writeln!( let _ = writeln!(
@@ -227,35 +180,35 @@ fn clients_text(s: &AppState) -> String {
out out
} }
// ── queue ────────────────────────────────────────────────────────────── // ── gate ──────────────────────────────────────────────────────────────
// `async` for symmetry with the other dispatch helpers — queue operations // `async` for symmetry with the other dispatch helpers — gate operations
// are sync today but might grow to await on a database in a future phase. // are sync today but might grow to await on a database in a future phase.
#[allow(clippy::unused_async)] #[allow(clippy::unused_async)]
async fn queue_command(s: &AppState, args: &[String]) -> Result<String, String> { async fn gate_command(s: &AppState, args: &[String]) -> Result<String, String> {
match args.first().map(String::as_str) { match args.first().map(String::as_str) {
None | Some("list") => { None | Some("list") => {
let entries = s.queue.list(); let gs = s.queue.list();
if entries.is_empty() { if gs.is_empty() {
return Ok("(queue empty)".into()); return Ok("(no gates)".into());
} }
let mut out = String::new(); let mut out = String::new();
for entry in entries { for g in gs {
let _ = writeln!( let _ = writeln!(
out, out,
"#{:<3} {:<19} {:<16} target={}", "#{:<3} {:<19} {:<16} target={}",
entry.position, g.position,
entry.mac, g.mac,
entry.id, g.id,
entry.assigned_target.unwrap_or_else(|| "-".into()) g.assigned_target.unwrap_or_else(|| "-".into())
); );
} }
Ok(out) Ok(out)
} }
Some("assign-all") => { Some("assign-all") => {
let target = args let target = args.get(1).ok_or_else(|| {
.get(1) "usage: gate assign-all <iso_boot_entry_id>".to_string()
.ok_or_else(|| "usage: queue assign-all <iso_boot_entry_id>".to_string())?; })?;
let found = s let found = s
.iso_store .iso_store
.list() .list()
@@ -266,160 +219,59 @@ async fn queue_command(s: &AppState, args: &[String]) -> Result<String, String>
} }
let ids: Vec<_> = s.queue.list().into_iter().map(|g| g.id).collect(); let ids: Vec<_> = s.queue.list().into_iter().map(|g| g.id).collect();
let n = s.queue.assign(&ids, target); let n = s.queue.assign(&ids, target);
Ok(format!("assigned {n} queue entries -> {target}")) Ok(format!("assigned {n} gates -> {target}"))
} }
Some("assign") => { Some("assign") => {
let entry_id = args let entry_id = args
.get(1) .get(1)
.ok_or_else(|| "usage: queue assign <entry_id> <iso_boot_entry_id>".to_string())?; .ok_or_else(|| "usage: gate assign <entry_id> <iso_boot_entry_id>".to_string())?;
let target = args let target = args
.get(2) .get(2)
.ok_or_else(|| "usage: queue assign <entry_id> <iso_boot_entry_id>".to_string())?; .ok_or_else(|| "usage: gate assign <entry_id> <iso_boot_entry_id>".to_string())?;
let n = s.queue.assign(std::slice::from_ref(entry_id), target); let n = s.queue.assign(std::slice::from_ref(entry_id), target);
if n == 0 { if n == 0 {
return Err(format!("no such queue entry: {entry_id}")); return Err(format!("no such gate: {entry_id}"));
} }
Ok(format!("assigned 1 queue entry -> {target}")) Ok(format!("assigned 1 gate -> {target}"))
} }
Some("release") => { Some("release") => {
let entry_id = args let entry_id = args.get(1).ok_or_else(|| "usage: gate release <entry_id>".to_string())?;
.get(1)
.ok_or_else(|| "usage: queue release <entry_id>".to_string())?;
match s.queue.release(entry_id) { match s.queue.release(entry_id) {
Some(_) => Ok(format!("released {entry_id}")), Some(_) => Ok(format!("released {entry_id}")),
None => Err(format!("no such queue entry: {entry_id}")), None => Err(format!("no such gate: {entry_id}")),
} }
} }
Some(other) => Err(format!( Some(other) => Err(format!(
"unknown queue subcommand: {other}\ntry: queue [list|assign-all|assign|release]" "unknown gate subcommand: {other}\ntry: gate [list|assign-all|assign|release]"
)), )),
} }
} }
// ── share (v0.4.65: SMB shares) ───────────────────────────────────────── // ── nfs ────────────────────────────────────────────────────────────────
async fn smb_share_command(s: &AppState, args: &[String]) -> Result<String, String> { async fn nfs_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 shares = s.smb_shares.list(); let mounts = s.nfs.list();
if shares.is_empty() { if mounts.is_empty() {
return Ok("(no SMB shares configured)".into()); return Ok("(no NFS mounts configured)".into());
} }
let mut out = String::new(); let mut out = String::new();
let _ = writeln!( let _ = writeln!(
out, out,
"{:<24} {:<7} {:<6} {:<6} TARGET", "{:<24} {:<6} {:<7} {:<6} TARGET",
"ID", "STATUS", "AUTH", "ISOS" "ID", "VER", "STATUS", "ISOS"
); );
for m in shares { for m in mounts {
let status = if m.reachable { "ok" } else { "down" }; let status = if m.mounted { "ok" } else { "down" };
let auth = if m.guest { "guest" } else { "user" };
let _ = writeln!( let _ = writeln!(
out, out,
"{:<24} {:<7} {:<6} {:<6} //{}/{}", "{:<24} {:<6} {:<7} {:<6} {}:{}",
truncate(&m.id, 24),
status,
auth,
m.iso_count,
m.server,
m.share,
);
if let Some(e) = m.last_error {
let _ = writeln!(out, " error: {e}");
}
if let Some(h) = m.last_hint {
let _ = writeln!(out, " hint: {h}");
}
}
Ok(out)
}
Some("add") => {
// share add //server/share [guest|user:password]
let target = args.get(1).ok_or_else(|| {
"usage: share add //server/share [guest|user:password]".to_string()
})?;
// Accept either `//server/share` (UNC-style) or
// `server:share` (shorter to type).
let stripped = target.trim_start_matches('/').trim_start_matches('\\');
let (server, share) = if let Some((s, p)) = stripped.split_once('/') {
(s, p)
} else if let Some((s, p)) = stripped.split_once(':') {
(s, p)
} else {
return Err("target must be '//server/share' or 'server:share'".into());
};
// Auth spec: "guest" or "user:password". Default: guest.
let auth = args.get(2).cloned().unwrap_or_else(|| "guest".into());
let (guest, username, password) = if auth == "guest" {
(true, None, None)
} else if let Some((u, p)) = auth.split_once(':') {
(false, Some(u.to_string()), Some(p.to_string()))
} else {
return Err("auth must be 'guest' or 'user:password'".into());
};
let req = openpxe_iso_store::SmbAddRequest {
server: server.to_string(),
share: share.to_string(),
username,
password,
guest,
port: None,
};
match s.smb_shares.add(req).await {
Ok(m) => Ok(format!("added {} ({} isos)", m.id, m.iso_count)),
Err(e) => {
let mut out = format!("add failed: {}", e.error);
if let Some(h) = e.hint {
out.push_str("\nhint: ");
out.push_str(&h);
}
Err(out)
}
}
}
Some("remove") => {
let id = args
.get(1)
.ok_or_else(|| "usage: share remove <id>".to_string())?;
match s.smb_shares.remove(id).await {
Ok(()) => Ok(format!("removed {id}")),
Err(e) => Err(format!("remove failed: {e}")),
}
}
Some("scan") => {
let id = args
.get(1)
.ok_or_else(|| "usage: share scan <id>".to_string())?;
match s.smb_shares.rescan(id).await {
Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
Err(e) => Err(format!("scan failed: {e}")),
}
}
Some(other) => Err(format!(
"unknown share subcommand: {other}\ntry: share [list|add|remove|scan]"
)),
}
}
// ── nfs (v0.4.67: in-process NFSv3 via nfs3_client) ────────────────────
async fn nfs_share_command(s: &AppState, args: &[String]) -> Result<String, String> {
match args.first().map(String::as_str) {
None | Some("list") => {
let shares = s.nfs_shares.list();
if shares.is_empty() {
return Ok("(no NFS shares configured)".into());
}
let mut out = String::new();
let _ = writeln!(out, "{:<24} {:<7} {:<6} TARGET", "ID", "STATUS", "ISOS");
for m in shares {
let status = if m.reachable { "ok" } else { "down" };
let _ = writeln!(
out,
"{:<24} {:<7} {:<6} {}:{}",
truncate(&m.id, 24), truncate(&m.id, 24),
match m.version {
openpxe_iso_store::NfsVersion::V3 => "v3",
openpxe_iso_store::NfsVersion::V41 => "v4.1",
},
status, status,
m.iso_count, m.iso_count,
m.server, m.server,
@@ -428,156 +280,50 @@ async fn nfs_share_command(s: &AppState, args: &[String]) -> Result<String, Stri
if let Some(e) = m.last_error { if let Some(e) = m.last_error {
let _ = writeln!(out, " error: {e}"); let _ = writeln!(out, " error: {e}");
} }
if let Some(h) = m.last_hint {
let _ = writeln!(out, " hint: {h}");
}
} }
Ok(out) Ok(out)
} }
Some("add") => { Some("mount") => {
// nfs add <server>:<export> [port] // nfs mount <server>:<export> [v3|v41] [ro|rw]
let target = args let target = args
.get(1) .get(1)
.ok_or_else(|| "usage: nfs add <server>:<export> [port]".to_string())?; .ok_or_else(|| "usage: nfs mount <server>:<export> [v3|v41] [ro|rw]".to_string())?;
let (server, export) = target let (server, export) = target
.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 port = args.get(2).and_then(|s| s.parse::<u16>().ok()); let version = match args.get(2).map(String::as_str) {
Some("v3") => openpxe_iso_store::NfsVersion::V3,
Some("v41") | None => openpxe_iso_store::NfsVersion::V41,
Some(other) => return Err(format!("unknown nfs version: {other} (expect v3 or v41)")),
};
let read_only = !matches!(args.get(3).map(String::as_str), Some("rw"));
let req = openpxe_iso_store::NfsAddRequest { let req = openpxe_iso_store::NfsAddRequest {
server: server.to_string(), server: server.to_string(),
export: export.to_string(), export: export.to_string(),
port, version,
read_only,
}; };
match s.nfs_shares.add(req).await { match s.nfs.add(req).await {
Ok(m) => Ok(format!("added {} ({} isos)", m.id, m.iso_count)), Ok(m) => Ok(format!("mounted {} ({} isos)", m.id, m.iso_count)),
Err(e) => { Err(e) => Err(format!("mount failed: {e}")),
let mut out = format!("add failed: {}", e.error);
if let Some(h) = e.hint {
out.push_str("\nhint: ");
out.push_str(&h);
}
Err(out)
}
} }
} }
Some("remove") => { Some("unmount") => {
let id = args let id = args.get(1).ok_or_else(|| "usage: nfs unmount <id>".to_string())?;
.get(1) match s.nfs.remove(id).await {
.ok_or_else(|| "usage: nfs remove <id>".to_string())?; Ok(()) => Ok(format!("unmounted {id}")),
match s.nfs_shares.remove(id).await { Err(e) => Err(format!("unmount failed: {e}")),
Ok(()) => Ok(format!("removed {id}")),
Err(e) => Err(format!("remove failed: {e}")),
} }
} }
Some("scan") => { Some("scan") => {
let id = args let id = args.get(1).ok_or_else(|| "usage: nfs scan <id>".to_string())?;
.get(1) match s.nfs.rescan(id).await {
.ok_or_else(|| "usage: nfs scan <id>".to_string())?;
match s.nfs_shares.rescan(id).await {
Ok(n) => Ok(format!("re-scanned {id}: {n} isos")), Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
Err(e) => Err(format!("scan failed: {e}")), Err(e) => Err(format!("scan failed: {e}")),
} }
} }
Some(other) => Err(format!( Some(other) => Err(format!(
"unknown nfs subcommand: {other}\ntry: nfs [list|add|remove|scan]" "unknown nfs subcommand: {other}\ntry: nfs [list|mount|unmount|scan]"
)),
}
}
// ── sftp (v0.5.5) ────────────────────────────────────────────────────────
//
// Parallel to nfs_share_command. The terminal `add` only supports
// password auth — pasting a multiline PEM private key through the
// terminal is impractical, so key-based shares are added via the WebUI.
async fn sftp_share_command(s: &AppState, args: &[String]) -> Result<String, String> {
match args.first().map(String::as_str) {
None | Some("list") => {
let shares = s.sftp_shares.list();
if shares.is_empty() {
return Ok("(no SFTP shares configured)".into());
}
let mut out = String::new();
let _ = writeln!(out, "{:<24} {:<7} {:<6} TARGET", "ID", "STATUS", "ISOS");
for m in shares {
let status = if m.reachable { "ok" } else { "down" };
let _ = writeln!(
out,
"{:<24} {:<7} {:<6} {}@{}:{}",
truncate(&m.id, 24),
status,
m.iso_count,
m.username,
m.server,
m.export,
);
if let Some(e) = m.last_error {
let _ = writeln!(out, " error: {e}");
}
if let Some(h) = m.last_hint {
let _ = writeln!(out, " hint: {h}");
}
}
Ok(out)
}
Some("add") => {
// sftp add <user>@<server>:<export> <password> [port]
let target = args.get(1).ok_or_else(|| {
"usage: sftp add <user>@<server>:<export> <password> [port] \
(key auth: use the WebUI)"
.to_string()
})?;
let password = args
.get(2)
.ok_or_else(|| "a password is required (key auth: use the WebUI)".to_string())?;
let (user, rest) = target
.split_once('@')
.ok_or_else(|| "target must be 'user@server:/export'".to_string())?;
let (server, export) = rest
.split_once(':')
.ok_or_else(|| "target must be 'user@server:/export'".to_string())?;
let port = args.get(3).and_then(|s| s.parse::<u16>().ok());
let req = openpxe_iso_store::SftpAddRequest {
server: server.to_string(),
export: export.to_string(),
username: Some(user.to_string()),
port,
password: Some(password.clone()),
private_key: None,
passphrase: None,
};
match s.sftp_shares.add(req).await {
Ok(m) => Ok(format!("added {} ({} isos)", m.id, m.iso_count)),
Err(e) => {
let mut out = format!("add failed: {}", e.error);
if let Some(h) = e.hint {
out.push_str("\nhint: ");
out.push_str(&h);
}
Err(out)
}
}
}
Some("remove") => {
let id = args
.get(1)
.ok_or_else(|| "usage: sftp remove <id>".to_string())?;
match s.sftp_shares.remove(id).await {
Ok(()) => Ok(format!("removed {id}")),
Err(e) => Err(format!("remove failed: {e}")),
}
}
Some("scan") => {
let id = args
.get(1)
.ok_or_else(|| "usage: sftp scan <id>".to_string())?;
match s.sftp_shares.rescan(id).await {
Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
Err(e) => Err(format!("scan failed: {e}")),
}
}
Some(other) => Err(format!(
"unknown sftp subcommand: {other}\ntry: sftp [list|add|remove|scan]"
)), )),
} }
} }
@@ -622,7 +368,10 @@ fn log_command(s: &AppState, args: &[String]) -> Result<String, String> {
Ok("log buffer cleared".into()) Ok("log buffer cleared".into())
} }
Some("tail") => { Some("tail") => {
let n: usize = args.get(1).and_then(|v| v.parse().ok()).unwrap_or(20); let n: usize = args
.get(1)
.and_then(|v| v.parse().ok())
.unwrap_or(20);
let lines = s.log_bus.recent(); let lines = s.log_bus.recent();
let start = lines.len().saturating_sub(n); let start = lines.len().saturating_sub(n);
let mut out = String::new(); let mut out = String::new();
@@ -713,28 +462,18 @@ OpenPXE terminal — available commands:
isos list registered ISOs isos list registered ISOs
clients list PXE clients seen this session clients list PXE clients seen this session
queue list list queued clients gate list list gated-deployment queue
queue assign <entry_id> <target> assign one queued client to a boot entry gate assign <entry_id> <target> assign one gate to a boot entry
queue assign-all <target> assign every waiting client gate assign-all <target> assign every waiting gate
queue release <entry_id> release one queued client gate release <entry_id> release one gate
share list list configured SMB shares nfs list list NFS mounts
share add //srv/share [auth] add an SMB share; auth = 'guest' or 'user:pass' nfs mount <s>:<e> [v3|v41] [ro|rw] add and mount an NFS share
share remove <id> forget an SMB share nfs unmount <id> unmount and forget a share
share scan <id> re-list a share for new ISOs nfs scan <id> re-scan a share for new ISOs
nfs list list configured NFSv3 shares smb status SMB (Samba) state
nfs add <srv>:<export> [port] add an NFSv3 share smb start | stop | reload control smbd
nfs remove <id> forget an NFS share
nfs scan <id> re-list an NFS share for new ISOs
sftp list list configured SFTP-over-SSH shares
sftp add <user>@<srv>:<export> <pass> [port] add an SFTP share (key auth: WebUI)
sftp remove <id> forget an SFTP share
sftp scan <id> re-list an SFTP share for new ISOs
smb status outbound Samba state (Windows install media)
smb start | stop | reload control the outbound smbd
log clear drop the in-memory log ring buffer log clear drop the in-memory log ring buffer
log tail [n] show the last n buffered lines (default 20) log tail [n] show the last n buffered lines (default 20)
@@ -749,10 +488,10 @@ mod tests {
#[test] #[test]
fn shell_split_basic() { fn shell_split_basic() {
assert_eq!(shell_split(""), Vec::<String>::new()); assert_eq!(shell_split(""), Vec::<String>::new());
assert_eq!(shell_split("share list"), vec!["share", "list"]); assert_eq!(shell_split("nfs list"), vec!["nfs", "list"]);
assert_eq!( assert_eq!(
shell_split("share add //nas/isos guest"), shell_split("nfs mount 10.0.0.5:/srv v41 ro"),
vec!["share", "add", "//nas/isos", "guest"] vec!["nfs", "mount", "10.0.0.5:/srv", "v41", "ro"]
); );
} }
@@ -782,10 +521,4 @@ mod tests {
assert_eq!(truncate("hi", 10), "hi"); assert_eq!(truncate("hi", 10), "hi");
assert_eq!(truncate("longerthanfive", 5), "long…"); assert_eq!(truncate("longerthanfive", 5), "long…");
} }
#[test]
fn help_uses_queue_language() {
assert!(HELP_TEXT.contains("queue list"));
assert!(HELP_TEXT.contains("queued clients"));
}
} }
-184
View File
@@ -1,184 +0,0 @@
//! Chunked upload sessions for browser-driven ISO uploads.
//!
//! The legacy multipart endpoint still exists for simple API clients, but
//! browsers get a better failure mode with raw chunks: progress advances after
//! each acknowledged write, partial files appear in the ISO directory
//! immediately, and reverse proxies are less likely to buffer an entire DVD
//! image before OpenPXE sees byte one.
use bytes::Bytes;
use openpxe_core::{Error, Result};
use openpxe_iso_store::{IsoMeta, IsoStore, UploadHandle};
use parking_lot::RwLock;
use serde::Serialize;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use uuid::Uuid;
const DEFAULT_CHUNK_SIZE: u64 = 8 * 1024 * 1024;
#[derive(Clone, Default)]
pub struct UploadSessions {
// v0.5.4: the registry is a sync `parking_lot::RwLock` — it's only ever
// briefly read/inserted/removed to look up a session, never held across
// an `.await`. The per-session lock below stays a `tokio::sync::Mutex`
// because `write_chunk` / `finish` are awaited while it's held.
inner: Arc<RwLock<HashMap<String, Arc<Mutex<UploadSession>>>>>,
}
struct UploadSession {
filename: String,
expected_size: Option<u64>,
offset: u64,
handle: Option<UploadHandle>,
}
#[derive(Debug, Clone, Serialize)]
pub struct UploadStarted {
pub upload_id: String,
pub iso_id: String,
pub filename: String,
pub offset: u64,
pub chunk_size: u64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum UploadAppend {
Progress { offset: u64 },
Complete { offset: u64, iso: Box<IsoMeta> },
}
impl UploadSessions {
pub async fn begin(
&self,
store: &IsoStore,
filename: &str,
expected_size: Option<u64>,
) -> Result<UploadStarted> {
if !filename.to_ascii_lowercase().ends_with(".iso") {
return Err(Error::Invalid("only .iso uploads accepted".to_string()));
}
let handle = store.begin_upload(filename).await?;
let iso_id = handle.id.clone();
let upload_id = Uuid::new_v4().to_string();
let session = UploadSession {
filename: filename.to_string(),
expected_size,
offset: 0,
handle: Some(handle),
};
self.inner
.write()
.insert(upload_id.clone(), Arc::new(Mutex::new(session)));
Ok(UploadStarted {
upload_id,
iso_id,
filename: filename.to_string(),
offset: 0,
chunk_size: DEFAULT_CHUNK_SIZE,
})
}
pub async fn append(
&self,
store: &IsoStore,
upload_id: &str,
offset: u64,
chunk: Bytes,
complete: bool,
) -> Result<UploadAppend> {
let Some(session_lock) = self.inner.read().get(upload_id).cloned() else {
return Err(Error::Invalid(format!("no such upload '{upload_id}'")));
};
let mut session = session_lock.lock().await;
if session.offset != offset {
return Err(Error::Invalid(format!(
"expected offset {}, got {offset}",
session.offset
)));
}
let new_offset = session
.offset
.checked_add(chunk.len() as u64)
.ok_or_else(|| Error::Invalid("upload offset overflow".to_string()))?;
if let Some(expected) = session.expected_size {
if new_offset > expected {
return Err(Error::Invalid(format!(
"chunk exceeds declared upload size {expected}"
)));
}
}
let Some(handle) = session.handle.as_mut() else {
return Err(Error::Invalid("upload already completed".to_string()));
};
if let Err(e) = handle.write_chunk(&chunk).await {
let handle = session.handle.take();
drop(session);
self.inner.write().remove(upload_id);
if let Some(handle) = handle {
let _ = handle.abort().await;
}
return Err(e);
}
session.offset = new_offset;
if !complete {
return Ok(UploadAppend::Progress { offset: new_offset });
}
if let Some(expected) = session.expected_size {
if new_offset != expected {
return Err(Error::Invalid(format!(
"final chunk ended at {new_offset}, expected {expected}"
)));
}
}
let Some(handle) = session.handle.take() else {
return Err(Error::Invalid("upload already completed".to_string()));
};
let filename = session.filename.clone();
drop(session);
tracing::info!(
target: "openpxe::http::upload",
upload_id,
filename = %filename,
received_bytes = new_offset,
"chunked upload body complete; introspecting"
);
let meta = match handle.finish(store).await {
Ok(meta) => meta,
Err(e) => {
self.inner.write().remove(upload_id);
return Err(e);
}
};
self.inner.write().remove(upload_id);
Ok(UploadAppend::Complete {
offset: new_offset,
iso: Box::new(meta),
})
}
pub async fn abort(&self, upload_id: &str) -> Result<()> {
let Some(session_lock) = self.inner.write().remove(upload_id) else {
return Err(Error::Invalid(format!("no such upload '{upload_id}'")));
};
let mut session = session_lock.lock().await;
if let Some(handle) = session.handle.take() {
handle.abort().await?;
}
Ok(())
}
}
File diff suppressed because it is too large Load Diff
+38 -60
View File
@@ -6,40 +6,43 @@
//! 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.
//! //!
//! Filename convention (matches `ClientArch::ipxe_bootfile_mode`): //! Filename convention (matches `ClientArch::ipxe_bootfile`):
//!
//! DriverMode::Firmware (default — reuse the firmware UNDI/SNP NIC stack):
//! - `undionly.kpxe` — Legacy x86 BIOS //! - `undionly.kpxe` — Legacy x86 BIOS
//! - `snponly-i386.efi` — IA32 UEFI //! - `snponly-i386.efi` — IA32 UEFI
//! - `snponly.efi` — x86_64 UEFI //! - `snponly.efi` — x86_64 UEFI
//! - `snponly-arm32.efi` — ARM32 UEFI
//! - `snponly-arm64.efi` — ARM64 UEFI //! - `snponly-arm64.efi` — ARM64 UEFI
//! //! - `ipxe.efi` (fallback) — UEFI with bundled drivers, if snponly fails on a NIC
//! DriverMode::Builtin (v0.6.1 automatic fallback — iPXE's own NIC drivers,
//! advertised when a firmware-net boot fails to chainload):
//! - `ipxe.pxe` — Legacy x86 BIOS
//! - `ipxe-i386.efi` — IA32 UEFI
//! - `ipxe.efi` — x86_64 UEFI (built from source with PNG)
//! - `ipxe-arm64.efi` — ARM64 UEFI
//!
//! - `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 openpxe_core::{ClientArch, DriverMode}; use openpxe_core::ClientArch;
use rust_embed::Embed; use rust_embed::Embed;
#[derive(Embed)] #[derive(Embed)]
#[folder = "../../assets/ipxe/"] #[folder = "../../assets/ipxe/"]
#[include = "*.kpxe"] #[include = "*.kpxe"]
#[include = "*.efi"] #[include = "*.efi"]
#[include = "*.pxe"]
#[include = "wimboot"] #[include = "wimboot"]
pub struct IpxeAssets; pub struct IpxeAssets;
/// Return a named embedded asset (e.g. `snponly.efi`, `wimboot`) as a /// Return the embedded iPXE binary for `arch`, or `None` if we didn't bundle
/// `Cow` over the embedded bytes. In release builds the data is borrowed /// one for that architecture.
/// straight from the binary's rodata — **zero copy** — which matters #[must_use]
/// because the TFTP and HTTP serving paths hit this for every boot pub fn bootfile_bytes(arch: ClientArch) -> Option<Vec<u8>> {
/// (`ipxe.efi` is ~1 MiB). Debug builds read from disk and return Owned. let name = arch.ipxe_bootfile()?;
IpxeAssets::get(name).map(|f| f.data.into_owned())
}
/// Return a named asset directly (e.g. `wimboot`, or a fallback `ipxe.efi`).
#[must_use]
pub fn asset_bytes(name: &str) -> Option<Vec<u8>> {
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] #[must_use]
pub fn asset_slice(name: &str) -> Option<std::borrow::Cow<'static, [u8]>> { pub fn asset_slice(name: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
IpxeAssets::get(name).map(|f| f.data) IpxeAssets::get(name).map(|f| f.data)
@@ -48,53 +51,28 @@ pub fn asset_slice(name: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
/// Enumerate embedded asset filenames. Useful for startup logging so the /// Enumerate embedded asset filenames. Useful for startup logging so the
/// operator can immediately tell which architectures will work. /// operator can immediately tell which architectures will work.
pub fn list_assets() -> Vec<String> { pub fn list_assets() -> Vec<String> {
IpxeAssets::iter() IpxeAssets::iter().map(std::borrow::Cow::into_owned).collect()
.map(std::borrow::Cow::into_owned)
.collect()
} }
/// Log at startup which iPXE binaries are present and which are missing, for /// Log at startup which iPXE binaries are present and which are missing.
/// both driver modes. The Firmware-mode binaries are required for PXE on each
/// arch; the Builtin-mode binaries are the optional automatic NIC-driver
/// fallback (v0.6.1) — without one, escalation simply can't help that arch.
pub fn log_availability() { pub fn log_availability() {
let have: std::collections::HashSet<String> = list_assets().into_iter().collect(); let have: std::collections::HashSet<String> = list_assets().into_iter().collect();
let arches = [ let needed = [
ClientArch::LegacyX86, (ClientArch::LegacyX86, "undionly.kpxe"),
ClientArch::Ia32Uefi, (ClientArch::Ia32Uefi, "snponly-i386.efi"),
ClientArch::X64Uefi, (ClientArch::X64Uefi, "snponly.efi"),
// ARM32 UEFI deferred — no upstream binary published in either mode. // ARM32 UEFI deferred — no upstream snponly binary published.
ClientArch::Arm64Uefi, (ClientArch::Arm64Uefi, "snponly-arm64.efi"),
]; ];
for arch in arches { for (arch, name) in needed {
for mode in [DriverMode::Firmware, DriverMode::Builtin, DriverMode::Shim] { if have.contains(name) {
let Some(name) = arch.ipxe_bootfile_mode(mode) else { tracing::info!(target: "openpxe::ipxe", "bundled iPXE for {}: {}", arch.as_str(), name);
continue; } else {
}; tracing::warn!(
if have.contains(name) { target: "openpxe::ipxe",
tracing::info!( "MISSING iPXE binary for {}: {} — clients of this arch will not PXE boot",
target: "openpxe::ipxe", arch.as_str(), name
"bundled iPXE for {} [{mode:?}]: {name}", arch.as_str() );
);
} else if mode == DriverMode::Firmware {
tracing::warn!(
target: "openpxe::ipxe",
"MISSING iPXE binary for {} [{mode:?}]: {name} — clients of this arch will not PXE boot",
arch.as_str()
);
} else if mode == DriverMode::Builtin {
tracing::info!(
target: "openpxe::ipxe",
"no built-in-driver fallback for {} [{mode:?}]: {name} — auto NIC driver escalation unavailable for this arch",
arch.as_str()
);
} else {
tracing::info!(
target: "openpxe::ipxe",
"no signed shim chain for {} [{mode:?}]: {name} — Secure Boot clients of this arch can't be served",
arch.as_str()
);
}
} }
} }
} }
-20
View File
@@ -20,32 +20,12 @@ thiserror.workspace = true
anyhow.workspace = true anyhow.workspace = true
sha2.workspace = true sha2.workspace = true
hex.workspace = true hex.workspace = true
bcrypt.workspace = true
uuid.workspace = true uuid.workspace = true
time.workspace = true time.workspace = true
parking_lot.workspace = true parking_lot.workspace = true
bytes.workspace = true bytes.workspace = true
tempfile = "3.12" tempfile = "3.12"
libc = "0.2" libc = "0.2"
# v0.4.61: server-side compose of the operator's uploaded raster into a
# fixed 1024x768 canvas so the PXE menu always gets a consistently-sized
# PNG regardless of what the operator uploaded. We use the bare-bones
# `image` crate (no default features) and explicitly enable only the
# decoders we accept on upload (PNG/JPEG/WebP/GIF) plus the PNG
# encoder. Keeps the build slim — no JPEG2000, TIFF, BMP, etc.
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
# v0.4.67: pure-Rust NFSv3 client for reading remote ISOs without a
# kernel mount. See crates/iso-store/src/nfs_share.rs for usage.
nfs3_client = { workspace = true }
nfs3_types = { workspace = true }
# v0.5.5: pure-Rust SSH/SFTP client (ring backend) for the SFTP remote
# share path. See crates/iso-store/src/sftp_share.rs for usage.
russh = { workspace = true }
russh-sftp = { workspace = true }
# Needed for the Stream trait that wraps the mpsc receiver feeding
# NFS read-loop bytes into axum's Body::from_stream.
futures = { workspace = true }
[dev-dependencies] [dev-dependencies]
tempfile = "3.12" tempfile = "3.12"
+3 -5
View File
@@ -25,11 +25,9 @@ pub enum BootKind {
wimboot_url: String, wimboot_url: String,
files: Vec<(String, String)>, files: Vec<(String, String)>,
}, },
/// SAN-boot the raw ISO as an emulated CD (iPXE `sanboot`). The emulated /// Last-resort: SAN-boot the ISO as an emulated CD. Only works for small
/// CD is backed by on-demand HTTP range reads, so ISO size is *not* a /// ISOs (<~1 GiB) and older distros. Kept for completeness, not the
/// constraint — this is the primary path for Windows (v0.5.8) and for any /// default.
/// El Torito-bootable image we don't special-case: ESXi/VMvisor
/// installers, BSDs, firmware/diagnostic tools, custom spins (v0.6.0).
SanBootIso { iso_url: String }, SanBootIso { iso_url: String },
} }
+22 -253
View File
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
use std::io::{Read, Seek, SeekFrom}; use std::io::{Read, Seek, SeekFrom};
use std::path::Path; use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum DistroFamily { pub enum DistroFamily {
DebianUbuntu, DebianUbuntu,
@@ -22,22 +22,10 @@ pub enum DistroFamily {
Arch, Arch,
Alpine, Alpine,
WindowsPe, WindowsPe,
#[default]
Unknown, Unknown,
} }
/// Bumped whenever the introspection logic changes in a way that should #[derive(Debug, Clone, Serialize, Deserialize)]
/// re-classify already-uploaded ISOs. On startup the store re-runs
/// `introspect` on any *local* ISO whose persisted report predates this
/// revision (see `IsoStore::load_from_disk`), so an upgrade fixes stale
/// metadata — e.g. a Windows 11 ISO tagged `Unknown` by an older binary —
/// without the operator having to delete and re-upload it.
///
/// rev 1 (v0.5.9): added El Torito boot-catalog detection + broadened
/// Windows (UDF/UTF-16) detection becomes retroactive.
pub const INTROSPECT_REV: u32 = 1;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IntrospectionReport { pub struct IntrospectionReport {
pub family: DistroFamily, pub family: DistroFamily,
pub volume_label: Option<String>, pub volume_label: Option<String>,
@@ -47,28 +35,17 @@ pub struct IntrospectionReport {
pub initrd_paths: Vec<String>, pub initrd_paths: Vec<String>,
/// True if `sources/boot.wim` present — Windows install media. /// True if `sources/boot.wim` present — Windows install media.
pub has_boot_wim: bool, pub has_boot_wim: bool,
/// True if the ISO carries an El Torito boot catalog — i.e. it is
/// bootable by BIOS/UEFI firmware and therefore by iPXE `sanboot`
/// (emulated CD). This is the authoritative "can this boot at all?"
/// signal for ISOs we can't classify as Linux or Windows (BSDs, ESXi,
/// firmware tools, custom spins). A *data* ISO (e.g. a VMware vCenter
/// appliance bundle) has no boot catalog and reports `false`. v0.5.9.
#[serde(default)]
pub el_torito: bool,
/// Revision of the introspection logic that produced this report. Old
/// `meta.json` files without the field deserialize as 0, which is
/// below [`INTROSPECT_REV`], triggering a one-time re-introspect on
/// the next startup. v0.5.9.
#[serde(default)]
pub introspect_rev: u32,
} }
/// Probe an ISO file on disk. Never fails — on unrecoverable IO error we log /// Probe an ISO file on disk. Never fails — on unrecoverable IO error we log
/// and return an `Unknown` family so the uploader still sees a record. /// and return an `Unknown` family so the uploader still sees a record.
pub fn introspect(path: &Path) -> IntrospectionReport { pub fn introspect(path: &Path) -> IntrospectionReport {
let mut report = IntrospectionReport { let mut report = IntrospectionReport {
introspect_rev: INTROSPECT_REV, family: DistroFamily::Unknown,
..Default::default() volume_label: None,
kernel_path: None,
initrd_paths: Vec::new(),
has_boot_wim: false,
}; };
let Ok(mut f) = std::fs::File::open(path) else { let Ok(mut f) = std::fs::File::open(path) else {
@@ -91,11 +68,6 @@ pub fn introspect(path: &Path) -> IntrospectionReport {
} }
} }
// Does the ISO have an El Torito boot catalog? This is what decides
// whether an ISO we *can't* otherwise classify is bootable at all —
// a bootable ISO sanboots; a data/appliance ISO (no catalog) can't.
report.el_torito = detect_el_torito(&mut f);
// Cheap content scan: read the first ~64 MiB, look for signature filenames. // Cheap content scan: read the first ~64 MiB, look for signature filenames.
// This is enough to identify `sources/boot.wim` (Windows) and common // This is enough to identify `sources/boot.wim` (Windows) and common
// kernel/initrd paths for the major Linux distros. // kernel/initrd paths for the major Linux distros.
@@ -103,53 +75,20 @@ pub fn introspect(path: &Path) -> IntrospectionReport {
let scan_bytes = 64 * 1024 * 1024; let scan_bytes = 64 * 1024 * 1024;
let mut buf = vec![0u8; 1024 * 1024]; let mut buf = vec![0u8; 1024 * 1024];
let mut read_total = 0usize; let mut read_total = 0usize;
// Size the haystack to what will actually be read — the scan cap or let mut haystack = Vec::with_capacity(scan_bytes.min(32 * 1024 * 1024));
// the file itself, whichever is smaller — so the fill never reallocs
// and a small ISO doesn't reserve the full 64 MiB.
let file_len = f.metadata().map_or(usize::MAX, |m| {
usize::try_from(m.len()).unwrap_or(usize::MAX)
});
let mut haystack = Vec::with_capacity(scan_bytes.min(file_len));
while read_total < scan_bytes { while read_total < scan_bytes {
let n = f.read(&mut buf).unwrap_or(0); let n = f.read(&mut buf).unwrap_or(0);
if n == 0 { if n == 0 { break; }
break;
}
haystack.extend_from_slice(&buf[..n]); haystack.extend_from_slice(&buf[..n]);
read_total += n; read_total += n;
} }
// `sources/boot.wim` is the definitive Windows-install-media marker
// when the ISO exposes ASCII (ISO9660/Joliet) names. `contains_ascii`
// is case-insensitive, so one form covers BOOT.WIM / boot.wim and the
// backslash variant.
if contains_ascii(&haystack, b"sources/boot.wim") if contains_ascii(&haystack, b"sources/boot.wim")
|| contains_ascii(&haystack, b"sources\\boot.wim") || contains_ascii(&haystack, b"SOURCES/BOOT.WIM")
|| contains_ascii(&haystack, b"SOURCES\\BOOT.WIM")
{ {
report.has_boot_wim = true; report.has_boot_wim = true;
report.family = DistroFamily::WindowsPe; if report.family == DistroFamily::Unknown {
}
// v0.5.8: broaden Windows detection. Modern Windows 10/11 ISOs are
// UDF — filenames are stored as UTF-16 (so the ASCII scan above misses
// them) and the volume label is a cryptic Microsoft string (so
// `family_from_label` misses it too). Booting is via HTTP sanboot of
// the raw ISO (no boot.wim extraction), so we only need the *family*.
// Catch the common cases: well-known Windows markers in either ASCII
// or UTF-16LE within the first 16 MiB, plus a filename hint.
if report.family == DistroFamily::Unknown {
let head = &haystack[..haystack.len().min(16 * 1024 * 1024)];
let ascii_markers: [&[u8]; 4] = [
b"bootmgr",
b"sources/install.wim",
b"sources/install.esd",
b"efi/microsoft",
];
let utf16_markers = ["bootmgr", "boot.wim", "install.wim", "microsoft"];
let looks_windows = ascii_markers.iter().any(|m| contains_ascii(head, m))
|| utf16_markers.iter().any(|m| contains_utf16le_ci(head, m))
|| filename_looks_windows(path);
if looks_windows {
report.family = DistroFamily::WindowsPe; report.family = DistroFamily::WindowsPe;
} }
} }
@@ -168,11 +107,8 @@ fn family_from_label(label: &str) -> DistroFamily {
let l = label.to_ascii_lowercase(); let l = label.to_ascii_lowercase();
if l.contains("ubuntu") || l.contains("debian") || l.contains("mint") { if l.contains("ubuntu") || l.contains("debian") || l.contains("mint") {
DistroFamily::DebianUbuntu DistroFamily::DebianUbuntu
} else if l.contains("rhel") } else if l.contains("rhel") || l.contains("centos") || l.contains("fedora")
|| l.contains("centos") || l.contains("rocky") || l.contains("alma")
|| l.contains("fedora")
|| l.contains("rocky")
|| l.contains("alma")
{ {
DistroFamily::RhelFedora DistroFamily::RhelFedora
} else if l.contains("suse") || l.contains("opensuse") { } else if l.contains("suse") || l.contains("opensuse") {
@@ -191,107 +127,17 @@ fn family_from_label(label: &str) -> DistroFamily {
fn guess_kernel_initrd(family: DistroFamily) -> (Option<&'static str>, Vec<&'static str>) { fn guess_kernel_initrd(family: DistroFamily) -> (Option<&'static str>, Vec<&'static str>) {
match family { match family {
DistroFamily::DebianUbuntu => (Some("/casper/vmlinuz"), vec!["/casper/initrd"]), DistroFamily::DebianUbuntu => (Some("/casper/vmlinuz"), vec!["/casper/initrd"]),
DistroFamily::RhelFedora => ( DistroFamily::RhelFedora => (Some("/images/pxeboot/vmlinuz"), vec!["/images/pxeboot/initrd.img"]),
Some("/images/pxeboot/vmlinuz"), DistroFamily::OpenSuse => (Some("/boot/x86_64/loader/linux"), vec!["/boot/x86_64/loader/initrd"]),
vec!["/images/pxeboot/initrd.img"], DistroFamily::Arch => (Some("/arch/boot/x86_64/vmlinuz-linux"), vec!["/arch/boot/x86_64/initramfs-linux.img"]),
),
DistroFamily::OpenSuse => (
Some("/boot/x86_64/loader/linux"),
vec!["/boot/x86_64/loader/initrd"],
),
DistroFamily::Arch => (
Some("/arch/boot/x86_64/vmlinuz-linux"),
vec!["/arch/boot/x86_64/initramfs-linux.img"],
),
DistroFamily::Alpine => (Some("/boot/vmlinuz-lts"), vec!["/boot/initramfs-lts"]), DistroFamily::Alpine => (Some("/boot/vmlinuz-lts"), vec!["/boot/initramfs-lts"]),
DistroFamily::WindowsPe | DistroFamily::Unknown => (None, Vec::new()), DistroFamily::WindowsPe | DistroFamily::Unknown => (None, Vec::new()),
} }
} }
fn contains_ascii(haystack: &[u8], needle: &[u8]) -> bool { fn contains_ascii(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || haystack.len() < needle.len() { if needle.is_empty() || haystack.len() < needle.len() { return false; }
return false; haystack.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle))
}
haystack
.windows(needle.len())
.any(|w| w.eq_ignore_ascii_case(needle))
}
/// Case-insensitive search for an ASCII string encoded as UTF-16LE — the
/// way UDF (and thus modern Windows ISOs) store filenames. Each character
/// is two bytes: the ASCII low byte (compared case-insensitively) followed
/// by a 0 high byte. v0.5.8.
fn contains_utf16le_ci(haystack: &[u8], ascii: &str) -> bool {
let n = ascii.len();
if n == 0 || haystack.len() < n * 2 {
return false;
}
let lower: Vec<u8> = ascii.bytes().map(|b| b.to_ascii_lowercase()).collect();
haystack.windows(n * 2).any(|w| {
lower
.iter()
.enumerate()
.all(|(i, &c)| w[i * 2 + 1] == 0 && w[i * 2].to_ascii_lowercase() == c)
})
}
/// Filename heuristic: a stock Windows ISO almost always carries an obvious
/// token in its name (e.g. `..._windows_11_...`, `Win10`, `winserver`).
/// Used only as a last-resort family hint when the content scan and volume
/// label are inconclusive. v0.5.8.
fn filename_looks_windows(path: &Path) -> bool {
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_ascii_lowercase();
const TOKENS: [&str; 6] = [
"windows",
"winpe",
"win10",
"win11",
"winserver",
"win-server",
];
TOKENS.iter().any(|t| name.contains(t))
}
/// The boot-system identifier string in an El Torito Boot Record Volume
/// Descriptor (offset 7, NUL-padded to 32 bytes).
const EL_TORITO_ID: &[u8] = b"EL TORITO SPECIFICATION";
/// Detect an El Torito boot catalog — the marker that an ISO is bootable
/// by BIOS/UEFI firmware (and thus by iPXE `sanboot`).
///
/// The ISO9660 Volume Descriptor Set starts at LBA 16 (offset 0x8000) and
/// runs one 2048-byte descriptor per sector until a Set Terminator
/// (type 0xFF). A Boot Record descriptor (type 0x00) whose 32-byte boot
/// system identifier reads "EL TORITO SPECIFICATION" means the image
/// declares an El Torito boot catalog. We only confirm its presence — we
/// don't parse the catalog (sanboot/the firmware does that). The walk is
/// capped so a malformed/huge image can't spin us. v0.5.9.
fn detect_el_torito(f: &mut std::fs::File) -> bool {
let mut vd = [0u8; 2048];
for lba in 16u64..32 {
if f.seek(SeekFrom::Start(lba * 2048)).is_err() || f.read_exact(&mut vd).is_err() {
return false;
}
// Every descriptor in the set carries the "CD001" magic; once it's
// missing we've walked off the end of a valid set.
if &vd[1..6] != b"CD001" {
return false;
}
match vd[0] {
// Boot Record descriptor carrying the El Torito signature.
0x00 if vd[7..7 + EL_TORITO_ID.len()] == *EL_TORITO_ID => return true,
// Volume Descriptor Set Terminator — nothing bootable found.
0xFF => return false,
// Any other descriptor (incl. a non-El-Torito boot record) —
// keep walking the set.
_ => {}
}
}
false
} }
#[cfg(test)] #[cfg(test)]
@@ -300,87 +146,10 @@ mod tests {
#[test] #[test]
fn label_matching() { fn label_matching() {
assert_eq!( assert_eq!(family_from_label("Ubuntu 24.04"), DistroFamily::DebianUbuntu);
family_from_label("Ubuntu 24.04"), assert_eq!(family_from_label("Rocky-9-x86_64-dvd"), DistroFamily::RhelFedora);
DistroFamily::DebianUbuntu assert_eq!(family_from_label("openSUSE-Leap-15.6"), DistroFamily::OpenSuse);
);
assert_eq!(
family_from_label("Rocky-9-x86_64-dvd"),
DistroFamily::RhelFedora
);
assert_eq!(
family_from_label("openSUSE-Leap-15.6"),
DistroFamily::OpenSuse
);
assert_eq!(family_from_label("ARCH_202604"), DistroFamily::Arch); assert_eq!(family_from_label("ARCH_202604"), DistroFamily::Arch);
assert_eq!(family_from_label("weird-custom"), DistroFamily::Unknown); assert_eq!(family_from_label("weird-custom"), DistroFamily::Unknown);
} }
#[test]
fn utf16le_marker_matches_case_insensitively() {
// "boot.wim" encoded UTF-16LE, mixed case — UDF stores Windows
// filenames this way, which the ASCII scan can't see.
let s = "BOOT.WIM";
let utf16: Vec<u8> = s.bytes().flat_map(|b| [b, 0]).collect();
let mut hay = vec![0u8; 8];
hay.extend_from_slice(&utf16);
hay.extend_from_slice(&[1, 2, 3]);
assert!(contains_utf16le_ci(&hay, "boot.wim"));
assert!(contains_utf16le_ci(&hay, "Boot.Wim"));
assert!(!contains_utf16le_ci(&hay, "install.wim"));
// An ASCII (not UTF-16) occurrence must NOT match the UTF-16 scan.
assert!(!contains_utf16le_ci(b"boot.wim plain ascii", "boot.wim"));
}
#[test]
fn el_torito_boot_catalog_detected() {
let dir = tempfile::tempdir().unwrap();
// Helper: stamp a 2048-byte descriptor at `lba` with type + magic.
let stamp = |img: &mut [u8], lba: usize, ty: u8| {
let off = lba * 2048;
img[off] = ty;
img[off + 1..off + 6].copy_from_slice(b"CD001");
};
// Bootable image: PVD @16, El Torito Boot Record @17, terminator @18.
let mut boot = vec![0u8; 2048 * 19];
stamp(&mut boot, 16, 0x01);
stamp(&mut boot, 17, 0x00);
boot[17 * 2048 + 7..17 * 2048 + 7 + EL_TORITO_ID.len()].copy_from_slice(EL_TORITO_ID);
stamp(&mut boot, 18, 0xFF);
let bp = dir.path().join("boot.iso");
std::fs::write(&bp, &boot).unwrap();
let mut f = std::fs::File::open(&bp).unwrap();
assert!(
detect_el_torito(&mut f),
"El Torito boot record should match"
);
// Data/appliance image: PVD @16, terminator @17, no boot record.
let mut data = vec![0u8; 2048 * 18];
stamp(&mut data, 16, 0x01);
stamp(&mut data, 17, 0xFF);
let dp = dir.path().join("data.iso");
std::fs::write(&dp, &data).unwrap();
let mut f2 = std::fs::File::open(&dp).unwrap();
assert!(!detect_el_torito(&mut f2), "data ISO has no boot catalog");
}
#[test]
fn filename_hint_catches_windows_isos() {
use std::path::Path;
assert!(filename_looks_windows(Path::new(
"en-us_windows_11_iot_enterprise_ltsc_2024_x64_dvd.iso"
)));
assert!(filename_looks_windows(Path::new(
"Win10_22H2_English_x64.iso"
)));
assert!(filename_looks_windows(Path::new("winserver2022.iso")));
assert!(!filename_looks_windows(Path::new(
"ubuntu-24.04-desktop.iso"
)));
assert!(!filename_looks_windows(Path::new(
"Rocky-9.4-x86_64-dvd.iso"
)));
}
} }
+3 -30
View File
@@ -18,41 +18,14 @@
pub mod entry; pub mod entry;
pub mod introspect; pub mod introspect;
pub mod nfs_share; pub mod nfs;
pub mod pxe_logo;
pub mod sftp_share;
pub mod smb; pub mod smb;
pub mod smb_share;
pub mod store; pub mod store;
pub mod unattended;
pub mod windows; pub mod windows;
pub use entry::{BootEntry, BootKind, KernelArgs}; pub use entry::{BootEntry, BootKind, KernelArgs};
pub use introspect::{DistroFamily, IntrospectionReport}; pub use introspect::{DistroFamily, IntrospectionReport};
// v0.4.65: kernel-mount NFS is gone. SMB shares via Samba's userspace pub use nfs::{NfsAddRequest, NfsManager, NfsMount, NfsVersion};
// `smbclient` CLI replaced it — works in any container (no
// CAP_SYS_ADMIN, no host kernel modules), matching how Bootimus and
// every other PXE/imaging tool that supports network storage handles
// it.
pub use smb::{extract_windows_iso, SmbManager, SmbState}; pub use smb::{extract_windows_iso, SmbManager, SmbState};
pub use smb_share::{SmbAddRequest, SmbShare, SmbShareError, SmbShareManager, SmbStream}; pub use store::{generate_boot_entries_for, slugify_str, IsoMeta, IsoSource, IsoStore, UploadHandle};
// v0.4.67: NFS is back — this time as an in-process userspace NFSv3
// client (the `nfs3_client` crate) rather than a kernel mount. Same
// "works in any container" property as SMB, plus support for HTTP
// Range requests because NFSv3 READ3 takes an explicit offset.
pub use nfs_share::{NfsAddRequest, NfsShare, NfsShareError, NfsShareManager, NfsStream};
// v0.5.5: SFTP-over-SSH remote shares via the pure-Rust `russh` +
// `russh-sftp` crates (ring backend — no OpenSSL, no new C deps). Like
// NFS, supports HTTP Range requests because SFTP opens a seekable file
// handle. See crates/iso-store/src/sftp_share.rs.
pub use sftp_share::{
SftpAddRequest, SftpAuthKind, SftpShare, SftpShareError, SftpShareManager, SftpStream,
};
pub use store::{
generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore, UploadHandle,
};
pub use unattended::{
classify as classify_unattended, render_template, UnattendedKind, UnattendedMeta,
UnattendedStore, MAX_UNATTENDED_BYTES,
};
pub use windows::{WimPatcher, WinPatchState}; pub use windows::{WimPatcher, WinPatchState};
+564
View File
@@ -0,0 +1,564 @@
//! NFS share manager.
//!
//! Lets an operator mount a remote NFS export as an ISO source instead of
//! uploading every ISO into the container's PVC. Supports NFSv3 and
//! NFSv4.1 — the two versions the user explicitly asked for.
//!
//! ## How it works
//!
//! 1. Operator submits a mount spec via the Storage tab:
//! `{ server: "10.0.0.20", export: "/srv/isos", version: "v41" }`.
//! 2. We slugify a stable id, mkdir `<work_dir>/nfs/<id>/`, then shell out
//! to `/bin/mount -t nfs -o vers=...,ro,nolock server:export local`.
//! 3. On success we walk the mount point looking for `*.iso` files and
//! register each one with the `IsoStore` as an external source — same
//! introspection pipeline as a web upload, but no sha256 (the bytes
//! live on a remote machine; hashing them would suck them through the
//! network on every restart).
//! 4. On failure we record `last_error` on the spec and persist anyway
//! so the UI can show a row in red rather than silently dropping it.
//!
//! ## Operational notes
//!
//! - Mounting NFS inside a container needs `CAP_SYS_ADMIN` and the
//! `nfs-common` package. The default image ships these (see Dockerfile).
//! - On OpenShift, the SCC must allow `CAP_SYS_ADMIN`. The bundled SCC
//! doesn't — operators have to opt in by switching to a more privileged
//! SCC or running NFS mounts as a CSI driver outside the pod.
//! - Mount commands are issued sequentially under a single mutex to avoid
//! `mount` racing on the same target dir.
//!
//! ## Persistence
//!
//! Mount specs (without runtime state) live at `<work_dir>/nfs.json`,
//! re-mounted on startup. Mounts that fail to come back online keep their
//! spec and their `last_error` so the operator sees what happened.
use crate::introspect::{introspect, IntrospectionReport};
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
use parking_lot::Mutex;
use openpxe_core::{Error, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use time::OffsetDateTime;
use tokio::process::Command;
/// Wire-protocol versions we support. Keep this enum closed — silently
/// accepting "auto" or letting the kernel negotiate would mean operators
/// could never confirm which version is in use.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NfsVersion {
/// NFSv3 — UDP/TCP, separate `mountd` protocol. Required for many
/// older NAS appliances.
V3,
/// NFSv4.1 — single TCP port (2049), session-based. Modern default.
V41,
}
impl NfsVersion {
fn vers_arg(self) -> &'static str {
match self {
Self::V3 => "vers=3",
Self::V41 => "vers=4.1",
}
}
}
/// One configured mount. The id is generated from server+export so the
/// operator can re-add the same export idempotently.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NfsMount {
pub id: String,
pub server: String,
pub export: String,
pub version: NfsVersion,
/// Read-only by default — most ISO libraries are. Operators that need
/// write can flip this off but OpenPXE itself never writes.
pub read_only: bool,
/// Local mount point under `<work_dir>/nfs/`.
pub local_path: PathBuf,
/// Whether the mount is currently active.
pub mounted: bool,
/// Last error encountered on a `mount` or `umount` attempt; cleared on
/// success.
pub last_error: Option<String>,
#[serde(with = "time::serde::rfc3339::option")]
pub last_attempt: Option<OffsetDateTime>,
/// Number of `.iso` files found on the share (re-counted on each scan).
pub iso_count: u32,
}
/// Spec submitted by the UI. Server and export are normalized before use.
#[derive(Debug, Clone, Deserialize)]
pub struct NfsAddRequest {
pub server: String,
pub export: String,
#[serde(default = "default_version")]
pub version: NfsVersion,
#[serde(default = "default_ro")]
pub read_only: bool,
}
fn default_version() -> NfsVersion {
NfsVersion::V41
}
fn default_ro() -> bool {
true
}
#[derive(Debug, Default)]
struct Inner {
mounts: HashMap<String, NfsMount>,
}
/// Manages NFS mounts and surfaces them as ISO sources.
///
/// Cheap to clone — internal state is `Arc<Mutex<...>>`.
#[derive(Debug, Clone)]
pub struct NfsManager {
work_root: Arc<PathBuf>,
state_path: Arc<PathBuf>,
inner: Arc<Mutex<Inner>>,
iso_store: IsoStore,
/// Single-writer lock around the actual `mount`/`umount` shell-outs;
/// avoids racing on the same target directory.
mount_lock: Arc<tokio::sync::Mutex<()>>,
}
impl NfsManager {
/// Construct a manager rooted at `work_dir`. Mount points live under
/// `<work_dir>/nfs/<id>/`. State persists to `<work_dir>/nfs.json`.
#[must_use]
pub fn new(work_dir: &Path, iso_store: IsoStore) -> Self {
let work_root = work_dir.join("nfs");
let state_path = work_dir.join("nfs.json");
Self {
work_root: Arc::new(work_root),
state_path: Arc::new(state_path),
inner: Arc::new(Mutex::new(Inner::default())),
iso_store,
mount_lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
/// Where this manager mounts shares. Used by `IsoStore` to resolve
/// NFS-backed `IsoMeta`s to their on-disk path.
#[must_use]
pub fn mount_root(&self) -> PathBuf {
self.work_root.as_ref().clone()
}
/// Load persisted state and re-attempt every mount. Errors are logged
/// per-mount but never fail the call — startup must not block on a
/// remote NFS server being slow.
pub async fn load_and_remount(&self) -> Result<()> {
tokio::fs::create_dir_all(self.work_root.as_path()).await?;
let mounts = match tokio::fs::read_to_string(self.state_path.as_path()).await {
Ok(text) => serde_json::from_str::<Vec<NfsMount>>(&text).unwrap_or_default(),
Err(_) => Vec::new(),
};
for mut m in mounts {
// Always start from "not mounted" — the kernel state was lost
// when the process died. We'll try to remount each one.
m.mounted = false;
m.last_error = None;
self.inner.lock().mounts.insert(m.id.clone(), m.clone());
if let Err(e) = self.try_mount(&m.id).await {
tracing::warn!(
target: "openpxe::nfs",
id = %m.id, error = %e,
"could not remount NFS share on startup"
);
}
}
Ok(())
}
/// Add a new mount. Returns the resulting `NfsMount` (with `mounted`
/// reflecting reality) or an error if the spec was invalid.
pub async fn add(&self, req: NfsAddRequest) -> Result<NfsMount> {
let server = req.server.trim().to_string();
let export = req.export.trim().to_string();
if server.is_empty() {
return Err(Error::Invalid("server is required".into()));
}
if !export.starts_with('/') {
return Err(Error::Invalid("export path must start with '/'".into()));
}
let id = mount_id(&server, &export);
let local_path = self.work_root.join(&id);
tokio::fs::create_dir_all(&local_path).await?;
let mount = NfsMount {
id: id.clone(),
server,
export,
version: req.version,
read_only: req.read_only,
local_path,
mounted: false,
last_error: None,
last_attempt: None,
iso_count: 0,
};
self.inner.lock().mounts.insert(id.clone(), mount);
self.persist_locked();
self.try_mount(&id).await?;
Ok(self.get(&id).expect("mount just inserted"))
}
/// Unmount and forget a share. Removes any ISOs it contributed from
/// the IsoStore and deletes the local mount point. Idempotent.
pub async fn remove(&self, id: &str) -> Result<()> {
// Best-effort umount; even if it fails (e.g. server unreachable)
// we still want to drop the in-memory record.
let _ = self.umount_one(id).await;
let local_path = {
let mut g = self.inner.lock();
g.mounts.remove(id).map(|m| m.local_path)
};
self.persist_locked();
self.iso_store.drop_external_source(id);
if let Some(p) = local_path {
// rmdir only — never recurse, the mount could still be live
// on some kernel error path and we don't want to nuke a
// remote filesystem.
let _ = tokio::fs::remove_dir(&p).await;
}
Ok(())
}
/// Re-scan a mounted share for ISOs, refreshing the IsoStore.
pub async fn rescan(&self, id: &str) -> Result<u32> {
let mount = self
.get(id)
.ok_or_else(|| Error::Invalid(format!("no such mount '{id}'")))?;
if !mount.mounted {
return Err(Error::Invalid(format!("mount '{id}' is not active")));
}
let count = self.scan_and_register(&mount).await?;
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
m.iso_count = count;
}
self.persist_locked();
Ok(count)
}
/// Snapshot of every configured mount.
#[must_use]
pub fn list(&self) -> Vec<NfsMount> {
let g = self.inner.lock();
let mut v: Vec<_> = g.mounts.values().cloned().collect();
v.sort_by(|a, b| a.id.cmp(&b.id));
v
}
/// Look up a single mount by id.
#[must_use]
pub fn get(&self, id: &str) -> Option<NfsMount> {
self.inner.lock().mounts.get(id).cloned()
}
// ── internals ─────────────────────────────────────────────────────
async fn try_mount(&self, id: &str) -> Result<()> {
let _g = self.mount_lock.lock().await;
let m = self
.get(id)
.ok_or_else(|| Error::Invalid(format!("no such mount '{id}'")))?;
let now = OffsetDateTime::now_utc();
// Already mounted? Skip — `mount` would error on a busy target
// and confuse the operator's UI status.
if is_mountpoint(&m.local_path).await {
self.update_status(id, true, None, now);
// Even though already mounted, we still want a fresh ISO count.
let count = self.scan_and_register(&m).await.unwrap_or(0);
self.update_iso_count(id, count);
return Ok(());
}
let opts = mount_options(&m);
let target = format!("{}:{}", m.server, m.export);
let output = Command::new("mount")
.arg("-t")
.arg("nfs")
.arg("-o")
.arg(&opts)
.arg(&target)
.arg(&m.local_path)
.output()
.await;
match output {
Ok(out) if out.status.success() => {
tracing::info!(
target: "openpxe::nfs",
id = %id, server = %m.server, export = %m.export,
version = ?m.version,
"NFS mount succeeded"
);
self.update_status(id, true, None, now);
let count = self.scan_and_register(&m).await.unwrap_or(0);
self.update_iso_count(id, count);
Ok(())
}
Ok(out) => {
let err = format!(
"mount exit {}: {}",
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stderr).trim()
);
tracing::warn!(target: "openpxe::nfs", id = %id, "{err}");
self.update_status(id, false, Some(err.clone()), now);
Err(Error::Invalid(err))
}
Err(e) => {
let err = format!("could not exec /bin/mount: {e}");
tracing::error!(target: "openpxe::nfs", id = %id, "{err}");
self.update_status(id, false, Some(err.clone()), now);
Err(Error::Invalid(err))
}
}
}
async fn umount_one(&self, id: &str) -> Result<()> {
let _g = self.mount_lock.lock().await;
let Some(m) = self.get(id) else { return Ok(()) };
if !is_mountpoint(&m.local_path).await {
self.update_status(id, false, None, OffsetDateTime::now_utc());
return Ok(());
}
// -l = lazy: detach immediately, finish when no process has a
// handle. Important if a stale ISO read is still in flight.
let out = Command::new("umount")
.arg("-l")
.arg(&m.local_path)
.output()
.await;
match out {
Ok(o) if o.status.success() => {
self.update_status(id, false, None, OffsetDateTime::now_utc());
Ok(())
}
Ok(o) => {
let e = format!(
"umount exit {}: {}",
o.status.code().unwrap_or(-1),
String::from_utf8_lossy(&o.stderr).trim()
);
self.update_status(id, false, Some(e.clone()), OffsetDateTime::now_utc());
Err(Error::Invalid(e))
}
Err(e) => {
let e = format!("could not exec /bin/umount: {e}");
self.update_status(id, false, Some(e.clone()), OffsetDateTime::now_utc());
Err(Error::Invalid(e))
}
}
}
/// Walk the mount point for `*.iso` files, introspect each one, and
/// register it with the IsoStore as an NFS-sourced entry. Returns the
/// count of ISOs registered.
async fn scan_and_register(&self, m: &NfsMount) -> Result<u32> {
// Drop any prior entries from this mount before re-registering, so
// a removed file disappears from the store.
self.iso_store.drop_external_source(&m.id);
let mut walker = tokio::fs::read_dir(&m.local_path).await?;
let mut count = 0u32;
while let Some(entry) = walker.next_entry().await? {
let p = entry.path();
if p.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
!= Some("iso")
{
continue;
}
let filename = match p.file_name().and_then(|s| s.to_str()) {
Some(f) => f.to_string(),
None => continue,
};
let size = tokio::fs::metadata(&p).await?.len();
// Introspection is sync + IO-bound (reads ISO9660 PVD). Push
// it to a blocking thread so the runtime stays responsive on
// a slow share.
let p_owned = p.clone();
let report: IntrospectionReport =
tokio::task::spawn_blocking(move || introspect(&p_owned))
.await
.map_err(|e| Error::Other(e.into()))?;
let id = format!("nfs-{}-{}", m.id, slugify_str(&filename));
let boot_entries = generate_boot_entries_for(&id, &filename, &report);
let source = IsoSource::Nfs {
mount_id: m.id.clone(),
relative_path: filename.clone(),
};
self.iso_store.register_external(
id,
filename,
size,
report,
boot_entries,
source,
);
count += 1;
}
Ok(count)
}
fn update_status(&self, id: &str, mounted: bool, err: Option<String>, ts: OffsetDateTime) {
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
m.mounted = mounted;
m.last_error = err;
m.last_attempt = Some(ts);
}
self.persist_locked();
}
fn update_iso_count(&self, id: &str, count: u32) {
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
m.iso_count = count;
}
self.persist_locked();
}
/// Atomically replace the on-disk JSON with the current state.
/// Persistence errors are logged, never propagated — settings live in
/// memory authoritatively, matching the SettingsStore policy.
fn persist_locked(&self) {
let mounts: Vec<NfsMount> = self.inner.lock().mounts.values().cloned().collect();
let path = self.state_path.as_path();
let tmp = path.with_extension("json.tmp");
let body = match serde_json::to_vec_pretty(&mounts) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::nfs", "serialize NFS state: {e}");
return;
}
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::nfs", "write NFS state tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, path) {
tracing::warn!(target: "openpxe::nfs", "rename NFS state: {e}");
}
}
}
fn mount_options(m: &NfsMount) -> String {
let mut opts = vec![m.version.vers_arg().to_string()];
if m.read_only {
opts.push("ro".into());
} else {
opts.push("rw".into());
}
// `nolock` for v3 — many storage appliances disable lockd; we don't
// need locking for read-only ISO access anyway.
if matches!(m.version, NfsVersion::V3) {
opts.push("nolock".into());
}
// Soft mount with a generous timeout — better to surface a hung share
// as a user-visible error than to wedge the iPXE client forever on a
// dead NFS server.
opts.push("soft".into());
opts.push("timeo=100".into());
opts.push("retrans=3".into());
opts.join(",")
}
fn mount_id(server: &str, export: &str) -> String {
let raw = format!("{server}{export}");
slugify_str(&raw)
}
/// Detect whether `path` is currently a mount point. We don't have
/// `is_mountpoint(2)`, so compare the parent's device id to the dir's;
/// if they differ the dir is a mount.
async fn is_mountpoint(path: &Path) -> bool {
let Some(parent) = path.parent() else {
return false;
};
let Ok(m1) = tokio::fs::metadata(path).await else {
return false;
};
let Ok(m2) = tokio::fs::metadata(parent).await else {
return false;
};
use std::os::unix::fs::MetadataExt;
m1.dev() != m2.dev()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_arg() {
assert_eq!(NfsVersion::V3.vers_arg(), "vers=3");
assert_eq!(NfsVersion::V41.vers_arg(), "vers=4.1");
}
#[test]
fn mount_options_v3_includes_nolock() {
let m = NfsMount {
id: "x".into(),
server: "s".into(),
export: "/e".into(),
version: NfsVersion::V3,
read_only: true,
local_path: PathBuf::from("/tmp/x"),
mounted: false,
last_error: None,
last_attempt: None,
iso_count: 0,
};
let opts = mount_options(&m);
assert!(opts.contains("vers=3"));
assert!(opts.contains("ro"));
assert!(opts.contains("nolock"));
assert!(opts.contains("soft"));
}
#[test]
fn mount_options_v41_no_nolock() {
let m = NfsMount {
id: "x".into(),
server: "s".into(),
export: "/e".into(),
version: NfsVersion::V41,
read_only: false,
local_path: PathBuf::from("/tmp/x"),
mounted: false,
last_error: None,
last_attempt: None,
iso_count: 0,
};
let opts = mount_options(&m);
assert!(opts.contains("vers=4.1"));
assert!(opts.contains("rw"));
assert!(!opts.contains("nolock"));
}
#[test]
fn mount_id_is_stable_and_safe() {
let a = mount_id("10.0.0.5", "/srv/isos");
let b = mount_id("10.0.0.5", "/srv/isos");
assert_eq!(a, b);
assert!(!a.contains('/'));
assert!(!a.contains('.'));
}
}
File diff suppressed because it is too large Load Diff
-264
View File
@@ -1,264 +0,0 @@
//! PXE boot-menu background compositor.
//!
//! The brief (v0.4.69): match iVentoy's polished graphical PXE screen.
//! iPXE built with `CONSOLE_FRAMEBUFFER` + `IMAGE_PNG` paints a PNG to
//! the framebuffer via `console --picture`, then draws the text menu on
//! top (the console's default background colour is rendered transparent
//! so the picture shows through the menu's blank cells). So what we
//! produce here is a **full-screen 1024×768 background**, not just a
//! floating logo:
//!
//! - a solid dark field (matches the WebUI dark theme so the product
//! feels consistent from browser to bare metal), with
//! - the operator's uploaded logo composited across the top, leaving
//! the lower ~two-thirds clear for the iPXE menu text.
//!
//! When no custom logo is uploaded we still return a designed
//! background — a dark field with a centered "rainbow-horizon" disc
//! echoing the bundled OpenPXE mark — so the boot screen is graphical
//! out of the box. This replaces the old ASCII wordmark entirely.
//!
//! iPXE does **not** scale pictures (confirmed against the decoder
//! source): the image is painted at native pixel size and the firmware
//! picks the smallest video mode that fits. 1024×768 is the universal
//! safe mode, so we pin the canvas there. Operators uploading a 4K logo
//! get it downscaled to fit the top band; tiny icons paint at native
//! size, centered.
//!
//! Input formats: anything the `image` crate decodes with our enabled
//! features — PNG, JPEG, WebP, GIF. iPXE itself only consumes PNG, so
//! we always *emit* PNG regardless of what the operator uploaded; a
//! WebP logo is transcoded here transparently.
use image::imageops::FilterType;
use image::{DynamicImage, ImageError, ImageFormat, Rgba, RgbaImage};
use std::io::Cursor;
/// Canvas dimensions. Pinned to 1024×768 — the universal framebuffer
/// mode every BIOS/UEFI console supports, and iPXE doesn't scale.
pub const CANVAS_W: u32 = 1024;
pub const CANVAS_H: u32 = 768;
/// Bounding box for the operator's logo across the top band. Wider than
/// the old floating-logo box because the logo now anchors a full
/// background rather than sitting alone on transparency.
const LOGO_MAX_W: u32 = 760;
const LOGO_MAX_H: u32 = 200;
/// Top margin from the canvas top to the logo's top edge.
const LOGO_TOP_MARGIN: u32 = 72;
/// Background fill — a near-black with a faint blue cast, matching the
/// WebUI's dark theme surface so the product reads as one piece from
/// browser to PXE screen.
const BG: Rgba<u8> = Rgba([11, 14, 22, 255]);
/// Compose the operator's uploaded raster (`Some`) — or the default
/// OpenPXE mark (`None`) — into a full-screen 1024×768 PNG background
/// and return the encoded bytes.
///
/// Errors only when a provided `src_bytes` can't be decoded; the
/// `None` path and the PNG encode are infallible for our fixed canvas.
pub fn compose_pxe_background(src_bytes: Option<&[u8]>) -> Result<Vec<u8>, ImageError> {
let mut canvas: RgbaImage = RgbaImage::from_pixel(CANVAS_W, CANVAS_H, BG);
match src_bytes {
Some(bytes) => {
let logo = image::load_from_memory(bytes)?;
let logo = downscale_to_fit(logo, LOGO_MAX_W, LOGO_MAX_H);
let logo_rgba = logo.to_rgba8();
let off_x = CANVAS_W.saturating_sub(logo_rgba.width()) / 2;
let off_y = LOGO_TOP_MARGIN.min(CANVAS_H.saturating_sub(logo_rgba.height()));
// `overlay` alpha-composites, so a transparent-background
// logo blends onto the dark field exactly as designed.
image::imageops::overlay(&mut canvas, &logo_rgba, off_x.into(), off_y.into());
}
None => draw_default_mark(&mut canvas),
}
let mut out = Vec::with_capacity(128 * 1024);
DynamicImage::ImageRgba8(canvas).write_to(&mut Cursor::new(&mut out), ImageFormat::Png)?;
Ok(out)
}
/// Back-compat shim for the old name — callers that pass a raw logo and
/// want it composited get the same result as `compose_pxe_background`
/// with `Some`.
pub fn compose_pxe_logo(src_bytes: &[u8]) -> Result<Vec<u8>, ImageError> {
compose_pxe_background(Some(src_bytes))
}
/// Paint a centered "rainbow-horizon" disc onto the dark canvas as the
/// default brand mark when no operator logo is set. Pure pixel math —
/// no font, no SVG rasterizer, no extra deps. A filled circle with a
/// left-to-right hue sweep echoes the bundled `logo.svg` motif.
// Casts here are all bounded small-range geometry (radius ≤ 90, canvas
// ≤ 1024) — precision loss / wrap is structurally impossible.
#[allow(clippy::cast_precision_loss, clippy::cast_possible_wrap)]
fn draw_default_mark(canvas: &mut RgbaImage) {
let radius: i32 = 90;
let cx = (CANVAS_W / 2) as i32;
let cy = (LOGO_TOP_MARGIN + 100) as i32;
// Four-stop horizontal sweep across the disc (teal → blue → violet
// → magenta) — the OpenPXE palette.
let stops = [
[0x22u8, 0xd3, 0xaa],
[0x3b, 0x82, 0xf6],
[0x8b, 0x5c, 0xf6],
[0xec, 0x48, 0x99],
];
let r2 = radius * radius;
for dy in -radius..=radius {
for dx in -radius..=radius {
if dx * dx + dy * dy > r2 {
continue;
}
// Position across the disc in [0,1] left→right.
let t = (f32::from(i16::try_from(dx + radius).unwrap_or(0)))
/ (f32::from(i16::try_from(2 * radius).unwrap_or(1)));
let color = gradient_at(&stops, t);
// Soft edge: fade alpha in the outer 3px ring.
let dist = ((dx * dx + dy * dy) as f32).sqrt();
let alpha = if dist > (radius as f32 - 3.0) {
let edge = (radius as f32 - dist).clamp(0.0, 3.0) / 3.0;
(edge * 255.0) as u8
} else {
255
};
let px = cx + dx;
let py = cy + dy;
if px >= 0 && py >= 0 && (px as u32) < CANVAS_W && (py as u32) < CANVAS_H {
blend_pixel(canvas, px as u32, py as u32, color, alpha);
}
}
}
}
/// Linear interpolate across an N-stop palette at position `t` in [0,1].
// `segments`/`idx` are ≤ palette length (4) — f32 cast is exact.
#[allow(clippy::cast_precision_loss)]
fn gradient_at(stops: &[[u8; 3]], t: f32) -> [u8; 3] {
let t = t.clamp(0.0, 1.0);
let segments = stops.len() - 1;
let scaled = t * segments as f32;
let idx = (scaled.floor() as usize).min(segments - 1);
let frac = scaled - idx as f32;
let a = stops[idx];
let b = stops[idx + 1];
[
lerp(a[0], b[0], frac),
lerp(a[1], b[1], frac),
lerp(a[2], b[2], frac),
]
}
fn lerp(a: u8, b: u8, t: f32) -> u8 {
(f32::from(a) + (f32::from(b) - f32::from(a)) * t).round() as u8
}
/// Alpha-blend `color` at `alpha` over the existing canvas pixel.
fn blend_pixel(canvas: &mut RgbaImage, x: u32, y: u32, color: [u8; 3], alpha: u8) {
let bg = canvas.get_pixel(x, y).0;
let a = f32::from(alpha) / 255.0;
let out = Rgba([
lerp(bg[0], color[0], a),
lerp(bg[1], color[1], a),
lerp(bg[2], color[2], a),
255,
]);
canvas.put_pixel(x, y, out);
}
fn downscale_to_fit(img: DynamicImage, max_w: u32, max_h: u32) -> DynamicImage {
let (w, h) = (img.width(), img.height());
if w <= max_w && h <= max_h {
return img;
}
img.resize(max_w, max_h, FilterType::Lanczos3)
}
#[cfg(test)]
mod tests {
use super::*;
use image::{ImageBuffer, Rgb};
fn solid_png(w: u32, h: u32, rgb: [u8; 3]) -> Vec<u8> {
let img: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_pixel(w, h, Rgb(rgb));
let mut out = Vec::with_capacity(4096);
DynamicImage::ImageRgb8(img)
.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
.unwrap();
out
}
#[test]
fn custom_logo_emits_canvas_sized_png_with_dark_field() {
let src = solid_png(120, 60, [200, 50, 50]);
let out = compose_pxe_background(Some(&src)).unwrap();
let img = image::load_from_memory(&out).unwrap().to_rgba8();
assert_eq!(img.width(), CANVAS_W);
assert_eq!(img.height(), CANVAS_H);
// A far corner should be the opaque dark background fill, not
// transparent — this is a full background now, not a floating
// logo on transparency.
let corner = img.get_pixel(CANVAS_W - 1, CANVAS_H - 1);
assert_eq!(corner.0, BG.0, "corner should be the dark fill");
}
#[test]
fn custom_logo_painted_in_top_band() {
let src = solid_png(100, 40, [10, 200, 10]);
let out = compose_pxe_background(Some(&src)).unwrap();
let canvas = image::load_from_memory(&out).unwrap().to_rgba8();
let cx = (CANVAS_W - 100) / 2;
let cy = LOGO_TOP_MARGIN;
let inside = canvas.get_pixel(cx + 10, cy + 10);
assert!(
inside.0[1] > 100 && inside.0[0] < 100,
"logo pixel color mismatch: {inside:?}"
);
}
#[test]
fn default_background_is_dark_with_a_painted_mark() {
let out = compose_pxe_background(None).unwrap();
let canvas = image::load_from_memory(&out).unwrap().to_rgba8();
assert_eq!(canvas.width(), CANVAS_W);
assert_eq!(canvas.height(), CANVAS_H);
// Corner is dark fill.
assert_eq!(canvas.get_pixel(2, CANVAS_H - 2).0, BG.0);
// Center of the disc is not the background fill (something was
// painted there).
let center = canvas.get_pixel(CANVAS_W / 2, LOGO_TOP_MARGIN + 100);
assert_ne!(center.0, BG.0, "default mark should paint over the field");
}
#[test]
fn webp_or_jpeg_input_is_accepted_and_transcoded_to_png() {
// Encode a JPEG and confirm the compositor decodes it and emits
// a valid PNG (iPXE only eats PNG, so transcoding is the point).
let img: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_pixel(80, 80, Rgb([90, 90, 90]));
let mut jpeg = Vec::new();
DynamicImage::ImageRgb8(img)
.write_to(&mut Cursor::new(&mut jpeg), ImageFormat::Jpeg)
.unwrap();
let out = compose_pxe_background(Some(&jpeg)).unwrap();
// Output must be a PNG (magic bytes) of canvas size.
assert_eq!(&out[..8], b"\x89PNG\r\n\x1a\n");
let img = image::load_from_memory(&out).unwrap();
assert_eq!(img.width(), CANVAS_W);
}
#[test]
fn unsupported_bytes_returns_error_not_panic() {
let r = compose_pxe_background(Some(b"\xde\xad\xbe\xef not an image"));
assert!(r.is_err());
}
#[test]
fn gradient_endpoints_match_stops() {
let stops = [[0, 0, 0], [255, 255, 255]];
assert_eq!(gradient_at(&stops, 0.0), [0, 0, 0]);
assert_eq!(gradient_at(&stops, 1.0), [255, 255, 255]);
}
}
File diff suppressed because it is too large Load Diff
+17 -80
View File
@@ -25,11 +25,11 @@
//! Samba), we return `SmbState::SmbdMissing` and the UI surfaces the //! Samba), we return `SmbState::SmbdMissing` and the UI surfaces the
//! gap. No panics, no retries, no silent failure. //! gap. No panics, no retries, no silent failure.
use parking_lot::Mutex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::sync::Arc; use std::sync::Arc;
use parking_lot::Mutex;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", tag = "state")] #[serde(rename_all = "snake_case", tag = "state")]
@@ -72,9 +72,7 @@ impl SmbManager {
/// ISO under `smb_dir/<slug>/` becomes a share named `<slug>`. Returns /// ISO under `smb_dir/<slug>/` becomes a share named `<slug>`. Returns
/// the sorted list. /// the sorted list.
pub fn discover_shares(&self) -> Vec<String> { pub fn discover_shares(&self) -> Vec<String> {
let Ok(rd) = std::fs::read_dir(&self.smb_dir) else { let Ok(rd) = std::fs::read_dir(&self.smb_dir) else { return vec![]; };
return vec![];
};
let mut out: Vec<String> = rd let mut out: Vec<String> = rd
.flatten() .flatten()
.filter(|e| e.path().is_dir()) .filter(|e| e.path().is_dir())
@@ -132,9 +130,7 @@ impl SmbManager {
let shares = match self.write_conf() { let shares = match self.write_conf() {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
let s = SmbState::Failed { let s = SmbState::Failed { reason: format!("write smb.conf: {e}") };
reason: format!("write smb.conf: {e}"),
};
*self.state.lock() = s.clone(); *self.state.lock() = s.clone();
return s; return s;
} }
@@ -143,8 +139,7 @@ impl SmbManager {
.args([ .args([
"--foreground", "--foreground",
"--no-process-group", "--no-process-group",
"--configfile", "--configfile", self.conf_path.to_str().unwrap_or(""),
self.conf_path.to_str().unwrap_or(""),
"--log-stdout", "--log-stdout",
]) ])
.stdin(Stdio::null()) .stdin(Stdio::null())
@@ -161,9 +156,7 @@ impl SmbManager {
s s
} }
Err(e) => { Err(e) => {
let s = SmbState::Failed { let s = SmbState::Failed { reason: format!("spawn smbd: {e}") };
reason: format!("spawn smbd: {e}"),
};
*self.state.lock() = s.clone(); *self.state.lock() = s.clone();
s s
} }
@@ -175,15 +168,11 @@ impl SmbManager {
#[allow(unsafe_code)] #[allow(unsafe_code)]
pub fn reconcile(&self) -> SmbState { pub fn reconcile(&self) -> SmbState {
let mut g = self.child.lock(); let mut g = self.child.lock();
if g.is_none() { if g.is_none() { return self.state.lock().clone(); }
return self.state.lock().clone();
}
let shares = match self.write_conf() { let shares = match self.write_conf() {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
let s = SmbState::Failed { let s = SmbState::Failed { reason: format!("write smb.conf: {e}") };
reason: format!("write smb.conf: {e}"),
};
*self.state.lock() = s.clone(); *self.state.lock() = s.clone();
return s; return s;
} }
@@ -202,13 +191,8 @@ impl SmbManager {
// covers this is `nix`, which pulls ~40 transitive deps for a // covers this is `nix`, which pulls ~40 transitive deps for a
// single signal send. One documented unsafe call is the better // single signal send. One documented unsafe call is the better
// tradeoff for a container-first project. // tradeoff for a container-first project.
unsafe { unsafe { libc::kill(pid, libc::SIGHUP); }
libc::kill(pid, libc::SIGHUP); let s = SmbState::Running { pid: pid as u32, shares };
}
let s = SmbState::Running {
pid: pid as u32,
shares,
};
*self.state.lock() = s.clone(); *self.state.lock() = s.clone();
s s
} else { } else {
@@ -228,19 +212,15 @@ impl SmbManager {
} }
fn smbd_present() -> bool { fn smbd_present() -> bool {
let Ok(paths) = std::env::var("PATH") else { let Ok(paths) = std::env::var("PATH") else { return false; };
return false;
};
for dir in std::env::split_paths(&paths) { for dir in std::env::split_paths(&paths) {
if dir.join("smbd").is_file() { if dir.join("smbd").is_file() { return true; }
return true;
}
} }
false false
} }
const SMB_CONF_GLOBAL: &str = r"[global] const SMB_CONF_GLOBAL: &str = r"[global]
workgroup = OPENPXE workgroup = PXEFORGE
server min protocol = SMB2 server min protocol = SMB2
smb ports = 445 smb ports = 445
log level = 1 log level = 1
@@ -255,15 +235,6 @@ lock directory = /tmp
state directory = /tmp state directory = /tmp
cache directory = /tmp cache directory = /tmp
pid directory = /tmp pid directory = /tmp
# WinPE reconnect hardening. Windows Setup can reboot mid-install and
# reconnect from the same IP; stale sessions/oplocks otherwise cause
# intermittent `net use` failures on the second stage.
reset on zero vc = yes
oplocks = no
kernel oplocks = no
level2 oplocks = no
strict locking = no
deadtime = 1
"; ";
/// Extract a Windows ISO at `iso_path` into `smb_dir/<slug>/`. Uses /// Extract a Windows ISO at `iso_path` into `smb_dir/<slug>/`. Uses
@@ -274,11 +245,7 @@ deadtime = 1
/// Idempotent: if the target dir already contains `sources/boot.wim`, we /// Idempotent: if the target dir already contains `sources/boot.wim`, we
/// skip extraction. Callers who want a forced re-extract should remove the /// skip extraction. Callers who want a forced re-extract should remove the
/// dir first. /// dir first.
pub fn extract_windows_iso( pub fn extract_windows_iso(iso_path: &Path, smb_dir: &Path, slug: &str) -> std::io::Result<PathBuf> {
iso_path: &Path,
smb_dir: &Path,
slug: &str,
) -> std::io::Result<PathBuf> {
let target = smb_dir.join(slug); let target = smb_dir.join(slug);
if target.join("sources").join("boot.wim").is_file() { if target.join("sources").join("boot.wim").is_file() {
tracing::debug!(target: "openpxe::smb", slug, "ISO already extracted, skipping"); tracing::debug!(target: "openpxe::smb", slug, "ISO already extracted, skipping");
@@ -296,9 +263,7 @@ pub fn extract_windows_iso(
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.output()?; .output()?;
if out.status.success() { if out.status.success() { return Ok(target); }
return Ok(target);
}
tracing::warn!( tracing::warn!(
target: "openpxe::smb", target: "openpxe::smb",
stderr=%String::from_utf8_lossy(&out.stderr), stderr=%String::from_utf8_lossy(&out.stderr),
@@ -313,9 +278,7 @@ pub fn extract_windows_iso(
.args(["-C"]) .args(["-C"])
.arg(&target) .arg(&target)
.output()?; .output()?;
if out.status.success() { if out.status.success() { return Ok(target); }
return Ok(target);
}
return Err(std::io::Error::other(format!( return Err(std::io::Error::other(format!(
"bsdtar failed: {}", "bsdtar failed: {}",
String::from_utf8_lossy(&out.stderr) String::from_utf8_lossy(&out.stderr)
@@ -331,9 +294,7 @@ fn which(cmd: &str) -> Option<PathBuf> {
let paths = std::env::var_os("PATH")?; let paths = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&paths) { for dir in std::env::split_paths(&paths) {
let p = dir.join(cmd); let p = dir.join(cmd);
if p.is_file() { if p.is_file() { return Some(p); }
return Some(p);
}
} }
None None
} }
@@ -359,9 +320,7 @@ mod tests {
let m = SmbManager::new(dir.path().into()); let m = SmbManager::new(dir.path().into());
let st = m.start(); let st = m.start();
// Restore PATH before asserting so any subsequent failure is legible. // Restore PATH before asserting so any subsequent failure is legible.
if let Some(p) = saved { if let Some(p) = saved { std::env::set_var("PATH", p); }
std::env::set_var("PATH", p);
}
assert_eq!(st, SmbState::SmbdMissing); assert_eq!(st, SmbState::SmbdMissing);
} }
@@ -388,27 +347,5 @@ mod tests {
assert!(conf.contains("guest ok = yes")); assert!(conf.contains("guest ok = yes"));
assert!(conf.contains("read only = yes")); assert!(conf.contains("read only = yes"));
assert!(conf.contains("server min protocol = SMB2")); assert!(conf.contains("server min protocol = SMB2"));
assert!(conf.contains("workgroup = OPENPXE"));
}
#[test]
fn write_conf_includes_winpe_reconnect_tuning() {
let dir = tempdir().unwrap();
let m = SmbManager::new(dir.path().into());
m.write_conf().unwrap();
let conf = std::fs::read_to_string(dir.path().join("smb.conf")).unwrap();
for expected in [
"reset on zero vc = yes",
"oplocks = no",
"kernel oplocks = no",
"level2 oplocks = no",
"strict locking = no",
"deadtime = 1",
] {
assert!(
conf.contains(expected),
"missing Windows reconnect Samba option {expected} in:\n{conf}"
);
}
} }
} }
-983
View File
@@ -1,983 +0,0 @@
//! SMB share consumer — replaces the kernel-mount NFS path that v0.4.64
//! shipped.
//!
//! ## Why SMB and not NFS
//!
//! v0.4.64 tried to make `mount -t nfs` work inside the OpenPXE
//! container. With `CAP_SYS_ADMIN` + `--privileged` we still hit the
//! same `mount.nfs: failed to apply fstab options` on Unraid because
//! Unraid's base kernel ships without the `nfs` / `nfsv4` client
//! modules loaded. No amount of container-side configuration can
//! load a kernel module on the host.
//!
//! SMB has the same kernel-side problem (`mount -t cifs` needs the
//! `cifs` kernel module) but unlike NFS it has a usable **userspace**
//! client: Samba's `smbclient` CLI. It speaks the SMB protocol over a
//! plain TCP socket, no kernel modules required. Bootimus uses the
//! same approach.
//!
//! ## How it works
//!
//! 1. Operator submits a share spec via the Storage tab:
//! `{ server: "192.168.1.51", share: "isos",
//! username, password, guest }`.
//! 2. We write credentials to a 0600-permission tempfile under
//! `<work_dir>/smb_creds/`. Passing them on the command line would
//! leak them through `ps` and the container's audit log.
//! 3. We test the connection by listing the share's root with
//! `smbclient //server/share -A creds_file -c 'ls *.iso'`. If the
//! server is unreachable, the share doesn't exist, or auth fails,
//! we get a clean error before persisting anything.
//! 4. We parse the `ls` output for `*.iso` filenames and sizes, and
//! register each one with the `IsoStore` as an
//! `IsoSource::Smb { share_id, relative_path }`.
//! 5. When a PXE client requests the bytes, the HTTP handler asks this
//! manager for an async reader. We spawn
//! `smbclient //server/share -A creds_file -c 'get file -'` and
//! pipe its stdout straight into the response body. No double
//! storage, no temp files.
//!
//! ## Why subprocess and not a Rust library
//!
//! The Debian runtime image already ships the `samba` package
//! (Dockerfile line 84) — `smbclient` is right there. Library options
//! like `pavao` wrap `libsmbclient` so they still pull in the same C
//! library at runtime. Subprocess is simpler, the API surface is
//! whatever the operator can verify with `smbclient` at a shell, and
//! debugging "what does smbclient see?" is trivial.
//!
//! ## Range request limitations (v0.4.65)
//!
//! `smbclient -c 'get file -'` is a sequential whole-file stream;
//! there's no native seek in the CLI. We honor full GETs and reject
//! HTTP Range requests with `416 Range Not Satisfiable` for
//! SMB-sourced ISOs. PXE clients in practice request the whole file:
//! iPXE chain loading, casper sanboot, wimboot all do sequential
//! streaming. A follow-up release can add libsmbclient-based seek if
//! a real workload needs it.
use crate::introspect::IntrospectionReport;
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
use openpxe_core::{Error, Result};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use time::OffsetDateTime;
use tokio::process::Command;
/// Default TCP port for SMB / CIFS. The wire protocol moved to 445
/// years ago; 139 (NetBIOS) is legacy and we don't expose it as an
/// option.
const DEFAULT_SMB_PORT: u16 = 445;
/// Maximum time we wait for a TCP connection to the SMB server during
/// the pre-flight probe. Same shape as the v0.4.64 NFS probe — short
/// enough that a wrong IP doesn't make the UI hang for 30s, long
/// enough that a slow appliance can still answer.
const PROBE_TIMEOUT: Duration = Duration::from_secs(4);
/// One configured SMB share. The id is derived from server+share so an
/// operator pasting the same coordinates twice gets idempotent
/// behaviour rather than a duplicate row.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmbShare {
pub id: String,
pub server: String,
pub share: String,
/// Username used for the SMB connection. Empty when `guest` is
/// true. Stored so the UI can echo it back; the password lives in
/// the separate credentials file (see `creds_path`).
pub username: String,
/// True when we're connecting with `-N` (anonymous / guest mode).
/// Most NAS appliances that expose ISO libraries do so as
/// guest-readable; this is the common case.
pub guest: bool,
/// TCP port — 445 unless the operator overrode it. Persisted so
/// the UI can echo it back.
#[serde(default = "default_port")]
pub port: u16,
/// Most recent error encountered talking to the share, or `None`
/// on success. Cleared every successful operation.
pub last_error: Option<String>,
/// Operator-friendly translation of `last_error`. None when we
/// don't have a friendlier rendition.
pub last_hint: Option<String>,
#[serde(with = "time::serde::rfc3339::option")]
pub last_scan: Option<OffsetDateTime>,
/// Number of `*.iso` files we know about on the share as of the
/// most recent scan.
pub iso_count: u32,
/// Whether the connection's currently working. `true` after a
/// successful scan, `false` after a failure. Drives the UI dot.
pub reachable: bool,
/// Path to the credentials file on disk. Internal — not surfaced
/// in the API JSON; we serialize it for restart-survival but the
/// UI doesn't render it.
#[serde(default)]
#[serde(skip_serializing)]
pub(crate) creds_path: Option<PathBuf>,
}
/// Submission from the UI / API.
#[derive(Debug, Clone, Deserialize)]
pub struct SmbAddRequest {
pub server: String,
pub share: String,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub password: Option<String>,
#[serde(default)]
pub guest: bool,
#[serde(default)]
pub port: Option<u16>,
}
fn default_port() -> u16 {
DEFAULT_SMB_PORT
}
/// Structured error surfaced to the API and rendered in the UI as two
/// lines: the raw `error` from smbclient + an actionable `hint`.
/// Mirrors the v0.4.64 NFS error shape so the storage tab can use a
/// single rendering path.
#[derive(Debug, Clone, Serialize)]
pub struct SmbShareError {
pub error: String,
pub stderr: String,
pub hint: Option<String>,
}
impl SmbShareError {
fn from_raw(error: impl Into<String>, stderr: impl Into<String>) -> Self {
let stderr = stderr.into();
let error = error.into();
let hint = hint_for(&stderr).or_else(|| hint_for(&error));
Self {
error,
stderr,
hint,
}
}
}
#[derive(Debug, Default)]
struct Inner {
shares: HashMap<String, SmbShare>,
}
/// Manages SMB shares and surfaces their ISOs through the IsoStore.
///
/// Cheap to clone — internal state is `Arc<Mutex<...>>`.
#[derive(Debug, Clone)]
pub struct SmbShareManager {
creds_root: Arc<PathBuf>,
state_path: Arc<PathBuf>,
inner: Arc<Mutex<Inner>>,
iso_store: IsoStore,
/// Serializes scan/list/get against the same share. smbclient
/// itself is fine concurrent across processes, but bundling
/// operations through a single lock makes test ordering and log
/// output predictable.
op_lock: Arc<tokio::sync::Mutex<()>>,
}
impl SmbShareManager {
/// Construct a manager rooted at `work_dir`. Credentials files
/// live under `<work_dir>/smb_creds/` with 0600 permissions; state
/// persists to `<work_dir>/smb_shares.json`.
#[must_use]
pub fn new(work_dir: &Path, iso_store: IsoStore) -> Self {
let creds_root = work_dir.join("smb_creds");
let state_path = work_dir.join("smb_shares.json");
Self {
creds_root: Arc::new(creds_root),
state_path: Arc::new(state_path),
inner: Arc::new(Mutex::new(Inner::default())),
iso_store,
op_lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
/// Load persisted state and re-scan every share. Errors per share
/// are logged and surfaced on the spec; the call itself never
/// fails — startup must not block on a single offline server.
pub async fn load_and_rescan(&self) -> Result<()> {
tokio::fs::create_dir_all(self.creds_root.as_path()).await?;
let shares = match tokio::fs::read_to_string(self.state_path.as_path()).await {
Ok(text) => serde_json::from_str::<Vec<SmbShare>>(&text).unwrap_or_default(),
Err(_) => Vec::new(),
};
for mut s in shares {
s.last_error = None;
s.last_hint = None;
s.reachable = false;
self.inner.lock().shares.insert(s.id.clone(), s.clone());
if let Err(e) = self.rescan_inner(&s.id).await {
tracing::warn!(
target: "openpxe::smb",
id = %s.id, server = %s.server, share = %s.share,
"rescan on startup failed: {e}"
);
}
}
Ok(())
}
/// Add or refresh a share. Validates the input, writes a creds
/// file, probes connectivity, and scans for ISOs.
pub async fn add(&self, req: SmbAddRequest) -> std::result::Result<SmbShare, SmbShareError> {
let server = normalize_server(&req.server);
let share = req.share.trim().trim_start_matches('/').to_string();
if server.is_empty() {
return Err(SmbShareError::from_raw("server is required", ""));
}
if share.is_empty() {
return Err(SmbShareError::from_raw("share name is required", ""));
}
if share.contains('/') {
return Err(SmbShareError::from_raw(
"share name should be the top-level share (e.g. 'isos'), not a path",
"",
));
}
if server.contains('\0') || share.contains('\0') {
return Err(SmbShareError::from_raw("NUL bytes are not allowed", ""));
}
let guest = req.guest;
let username = req.username.unwrap_or_default().trim().to_string();
let password = req.password.unwrap_or_default();
if !guest && username.is_empty() {
return Err(SmbShareError::from_raw(
"username is required when 'guest' is unchecked",
"",
));
}
let port = req.port.filter(|p| *p != 0).unwrap_or(DEFAULT_SMB_PORT);
let id = share_id(&server, &share);
// Pre-flight TCP probe so a wrong IP / firewall surfaces a
// clean error instead of one of smbclient's notoriously
// cryptic NT_STATUS codes.
if let Err((err, hint)) = tcp_probe(&server, port).await {
// No share is persisted yet; just return the error.
return Err(SmbShareError {
error: err,
stderr: String::new(),
hint: Some(hint),
});
}
// Write the creds file. Even guest mode gets a file (empty
// username/password) so the code path is uniform.
let creds_path = self.creds_root.join(format!("{id}.cred"));
if let Err(e) = self.write_creds(&creds_path, &username, &password).await {
return Err(SmbShareError::from_raw(
format!("could not write credentials file: {e}"),
"",
));
}
let spec = SmbShare {
id: id.clone(),
server,
share,
username,
guest,
port,
last_error: None,
last_hint: None,
last_scan: None,
iso_count: 0,
reachable: false,
creds_path: Some(creds_path),
};
self.inner.lock().shares.insert(id.clone(), spec);
self.persist_locked();
// Now actually talk to the server.
if let Err(e) = self.rescan_inner(&id).await {
let m = self.get(&id);
return Err(SmbShareError {
error: m
.as_ref()
.and_then(|m| m.last_error.clone())
.unwrap_or_else(|| e.to_string()),
stderr: String::new(),
hint: m.and_then(|m| m.last_hint),
});
}
Ok(self.get(&id).expect("just inserted"))
}
/// Remove a share: drops every ISO sourced from it, scrubs the
/// creds file, and forgets the spec. Idempotent.
pub async fn remove(&self, id: &str) -> Result<()> {
let creds_path = {
let mut g = self.inner.lock();
g.shares.remove(id).and_then(|s| s.creds_path)
};
self.iso_store.drop_external_source(id);
if let Some(p) = creds_path {
// Overwrite-then-unlink would be more thorough but the
// file is 0600 in a non-root-owned dir; rm is sufficient.
let _ = tokio::fs::remove_file(&p).await;
}
self.persist_locked();
Ok(())
}
/// Re-list the share and refresh the IsoStore entries.
pub async fn rescan(&self, id: &str) -> Result<u32> {
self.rescan_inner(id).await
}
/// Snapshot of every configured share, sorted by id for stable UI
/// rendering.
#[must_use]
pub fn list(&self) -> Vec<SmbShare> {
let g = self.inner.lock();
let mut v: Vec<_> = g.shares.values().cloned().collect();
v.sort_by(|a, b| a.id.cmp(&b.id));
v
}
/// Look up a share by id.
#[must_use]
pub fn get(&self, id: &str) -> Option<SmbShare> {
self.inner.lock().shares.get(id).cloned()
}
/// Open an async reader streaming an ISO out of the share. Used
/// by the HTTP ISO download handler.
///
/// Kept `async` for symmetry with the other I/O entrypoints —
/// spawning the child is sync today (no `.await` inside) but a
/// future addition (e.g. probing the share before spawn or
/// throttling concurrent smbclients) would need to await without
/// changing the call sites.
#[allow(clippy::unused_async)]
pub async fn stream_iso(&self, share_id: &str, filename: &str) -> Result<SmbStream> {
let share = self
.get(share_id)
.ok_or_else(|| Error::Invalid(format!("no such SMB share '{share_id}'")))?;
// Defensive: reject any filename that tries to escape the
// share root. smbclient itself accepts only filenames at the
// share root in our `get` form, but belt-and-suspenders.
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return Err(Error::Invalid(format!("invalid filename '{filename}'")));
}
let creds = share
.creds_path
.as_deref()
.ok_or_else(|| Error::Invalid("share has no credentials file".into()))?;
let target = format!("//{}/{}", share.server, share.share);
let mut cmd = Command::new("smbclient");
cmd.arg(&target)
.arg("-A")
.arg(creds)
.arg("-p")
.arg(share.port.to_string())
.arg("-c")
.arg(format!("get \"{filename}\" -"))
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null());
if share.guest {
cmd.arg("-N");
}
// v0.4.66: surface a useful error if smbclient isn't on the
// PATH. Shouldn't happen on the stock image but custom
// builds may strip it.
let mut child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Error::Invalid(
"smbclient binary not found on $PATH — install the \
Debian `smbclient` package or pull OpenPXE v0.4.66+"
.into(),
)
} else {
Error::Other(e.into())
}
})?;
let stdout = child
.stdout
.take()
.ok_or_else(|| Error::Invalid("smbclient stdout missing".into()))?;
Ok(SmbStream { child, stdout })
}
// ── internals ─────────────────────────────────────────────────────
async fn rescan_inner(&self, id: &str) -> Result<u32> {
let _g = self.op_lock.lock().await;
let share = self
.get(id)
.ok_or_else(|| Error::Invalid(format!("no such share '{id}'")))?;
let now = OffsetDateTime::now_utc();
// Drop prior entries so a deleted file disappears from the
// store on the next scan.
self.iso_store.drop_external_source(id);
let listing = match self.list_isos(&share).await {
Ok(l) => l,
Err((err, stderr)) => {
let combined = if stderr.is_empty() {
err.clone()
} else {
format!("{err}: {stderr}")
};
let hint = hint_for(&stderr).or_else(|| hint_for(&err));
self.update_status(id, 0, false, Some(combined.clone()), hint, now);
return Err(Error::Invalid(combined));
}
};
// For each ISO we found, we still need its size + a quick
// introspection pass. The introspection pass needs random
// access into the ISO9660 PVD which lives at offset 0x8000.
// For SMB sources we can't seek without downloading the file
// first, so we use a degenerate "unknown family" introspection
// report for the listing pass. Operators can rescan after the
// first PXE boot has touched the file if they want a real
// family detection. (Better: a follow-up release adds a tiny
// `smbclient -c 'get file -'` bounded read to do introspection
// without storing the whole ISO.)
let mut count = 0u32;
for entry in listing {
let iso_id = format!("smb-{}-{}", share.id, slugify_str(&entry.filename));
// SMB sources don't get a real introspection pass — that
// would require seeking into the ISO9660 PVD over the
// network, and smbclient CLI doesn't seek. We register an
// `Unknown` family so the boot-entry generator falls back
// to generic sanboot/wimboot detection from the filename
// and the operator gets *something* bootable. A follow-up
// release can do a bounded `smbclient get` of the first
// 64 KiB for real detection.
let report = IntrospectionReport::default();
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
let source = IsoSource::Smb {
share_id: share.id.clone(),
relative_path: entry.filename.clone(),
};
self.iso_store.register_external(
iso_id,
entry.filename,
entry.size,
report,
boot_entries,
source,
);
count += 1;
}
self.update_status(id, count, true, None, None, now);
tracing::info!(
target: "openpxe::smb",
id = %id, server = %share.server, share = %share.share,
iso_count = count,
"SMB share scanned"
);
Ok(count)
}
/// Spawn `smbclient //server/share -A creds -c "ls *.iso"` and
/// parse the output. Returns `(error_text, stderr_text)` on
/// failure so the caller can surface both.
async fn list_isos(
&self,
share: &SmbShare,
) -> std::result::Result<Vec<SmbListEntry>, (String, String)> {
let target = format!("//{}/{}", share.server, share.share);
let mut cmd = Command::new("smbclient");
cmd.arg(&target)
.arg("-A")
.arg(
share
.creds_path
.as_deref()
.ok_or_else(|| ("no credentials file".to_string(), String::new()))?,
)
.arg("-p")
.arg(share.port.to_string())
.arg("-c")
.arg("ls *.iso");
if share.guest {
cmd.arg("-N");
}
let output = match cmd.output().await {
Ok(o) => o,
Err(e) => {
// v0.4.66: distinguish ENOENT (missing binary) from
// other exec failures and pre-fill the hint so the
// UI shows a clear remediation instead of the bare
// "No such file or directory (os error 2)". This
// shouldn't fire on the stock image — the Dockerfile
// installs the `smbclient` package — but is a useful
// breadcrumb for anyone running OpenPXE in a stripped
// base image.
let stderr = if e.kind() == std::io::ErrorKind::NotFound {
"smbclient binary not found on $PATH".to_string()
} else {
String::new()
};
return Err((format!("could not exec smbclient: {e}"), stderr));
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
// smbclient writes most diagnostics to stdout too; merge
// them so we don't lose context.
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let combined = if stderr.is_empty() { stdout } else { stderr };
return Err((
format!("smbclient exit {}", output.status.code().unwrap_or(-1)),
combined,
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(parse_ls_iso(&stdout))
}
async fn write_creds(
&self,
path: &Path,
username: &str,
password: &str,
) -> std::io::Result<()> {
tokio::fs::create_dir_all(self.creds_root.as_path()).await?;
// Write the file with 0600 perms. `smbclient -A` accepts the
// standard pam_mount-style:
// username = foo
// password = bar
let body = format!(
"username = {}\npassword = {}\n",
username.replace('\n', ""),
password.replace('\n', ""),
);
// Synchronous file write to set perms atomically with the
// create — there's no async equivalent of OpenOptions+mode
// shared with the tokio API in std stable.
let path = path.to_path_buf();
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)?;
f.write_all(body.as_bytes())?;
Ok(())
})
.await
.map_err(std::io::Error::other)??;
Ok(())
}
fn update_status(
&self,
id: &str,
iso_count: u32,
reachable: bool,
err: Option<String>,
hint: Option<String>,
ts: OffsetDateTime,
) {
if let Some(s) = self.inner.lock().shares.get_mut(id) {
s.iso_count = iso_count;
s.reachable = reachable;
s.last_error = err;
s.last_hint = hint;
s.last_scan = Some(ts);
}
self.persist_locked();
}
/// Atomically replace the on-disk JSON. Persistence errors are
/// logged, never propagated.
fn persist_locked(&self) {
let shares: Vec<SmbShare> = self.inner.lock().shares.values().cloned().collect();
let path = self.state_path.as_path();
let tmp = path.with_extension("json.tmp");
let body = match serde_json::to_vec_pretty(&shares) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::smb", "serialize: {e}");
return;
}
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::smb", "write tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, path) {
tracing::warn!(target: "openpxe::smb", "rename: {e}");
}
}
}
/// Async-reader handle for an in-flight `smbclient get file -` stream.
/// Wraps the child process + its piped stdout; dropping it kills the
/// child.
#[derive(Debug)]
pub struct SmbStream {
/// Kept alive so the child isn't reaped while we're reading. The
/// `Drop` impl on `tokio::process::Child` sends SIGKILL on drop
/// when `kill_on_drop` is set; we leave that to the default
/// (no-kill) so a slow client doesn't tear down the pipe before
/// the OS finishes the read. The child exits naturally when its
/// stdout closes.
#[allow(dead_code)]
child: tokio::process::Child,
pub stdout: tokio::process::ChildStdout,
}
#[derive(Debug, Clone)]
struct SmbListEntry {
filename: String,
size: u64,
}
/// Parse `smbclient ls *.iso` output. The format is:
///
/// ```text
/// . D 0 Mon May 26 10:00:00 2026
/// .. D 0 Mon May 26 10:00:00 2026
/// ubuntu-22.04-desktop.iso A 3650912256 Mon May 26 11:00:00 2026
///
/// 4096 blocks of size 1048576. 1234 blocks available
/// ```
///
/// Each file line:
/// - starts with whitespace
/// - has the filename, then attribute flags (D=dir, A=archive, R=read-only,
/// H=hidden, S=system, N=normal), then size, then date.
///
/// We accept any line where the attributes column doesn't contain `D`
/// (i.e. not a directory) and the filename ends in `.iso` (case
/// insensitive).
fn parse_ls_iso(out: &str) -> Vec<SmbListEntry> {
let mut entries = Vec::new();
for raw in out.lines() {
let line = raw.trim();
// Skip blank lines, the connection-info banner, and the
// trailing "N blocks of size" summary. The actual filter for
// "is this a file listing?" is the attribute+size pattern
// detection below, which only matches real file rows.
if line.is_empty() || line.contains("blocks of size") {
continue;
}
// Find the attribute column: a short token of one or more of
// [DAHSRN] that follows a long-enough filename block.
// smbclient pads the filename to ~36 columns, so we can split
// on multiple consecutive spaces and then look for the
// attribute token.
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.len() < 3 {
continue;
}
// The last 5 tokens are typically: ATTR SIZE Day Mon DD HH:MM:SS YYYY
// (sometimes Day is missing depending on locale). Walk
// backwards to find ATTR + SIZE: ATTR is 1-6 chars of [DAHSRN],
// SIZE is digits.
let attr_idx = tokens.iter().enumerate().rev().find_map(|(i, t)| {
if i == 0 {
return None;
}
let next = tokens.get(i + 1)?;
let is_attr = !t.is_empty() && t.chars().all(|c| "DAHSRN".contains(c));
let is_size = next.chars().all(|c| c.is_ascii_digit()) && !next.is_empty();
if is_attr && is_size {
Some(i)
} else {
None
}
});
let Some(attr_idx) = attr_idx else { continue };
let attr = tokens[attr_idx];
// Directories aren't ISO files.
if attr.contains('D') {
continue;
}
let size_tok = tokens[attr_idx + 1];
let Ok(size) = size_tok.parse::<u64>() else {
continue;
};
// The filename is everything before the attribute token in
// the original (un-tokenized) line — we need the original
// because filenames can contain spaces.
// Locate the attribute token's start column by counting
// characters in the prior tokens + separators. Simpler: find
// the index of the attribute in the trimmed line by joining
// and trimming again.
let joined_before: String = tokens[..attr_idx].join(" ");
let name = joined_before.trim().to_string();
if name.is_empty() || name == "." || name == ".." {
continue;
}
if !name.to_ascii_lowercase().ends_with(".iso") {
continue;
}
entries.push(SmbListEntry {
filename: name,
size,
});
}
entries
}
/// Pre-flight TCP probe to `server:port`. Format matches v0.4.64 NFS
/// probe so the UI banner reads consistently.
async fn tcp_probe(server: &str, port: u16) -> std::result::Result<(), (String, String)> {
use tokio::net::TcpStream;
let addr = format!("{server}:{port}");
match tokio::time::timeout(PROBE_TIMEOUT, TcpStream::connect(&addr)).await {
Ok(Ok(_)) => Ok(()),
Ok(Err(e)) => Err((
format!("cannot reach SMB port: {addr}: {e}"),
format!(
"verify the SMB service is running on {server} and that port {port} is open"
),
)),
Err(_) => Err((
format!(
"cannot reach SMB port: {addr}: timed out after {}s",
PROBE_TIMEOUT.as_secs()
),
format!(
"no TCP answer from {server}:{port} within {}s — check the IP and any firewall in between",
PROBE_TIMEOUT.as_secs()
),
)),
}
}
/// Translate well-known smbclient stderr patterns into actionable
/// hints. Returns `None` when we don't have a translation.
fn hint_for(text: &str) -> Option<String> {
let s = text.to_ascii_lowercase();
if s.contains("smbclient binary not found")
|| s.contains("smbclient: no such file")
|| (s.contains("could not exec smbclient") && s.contains("os error 2"))
{
// v0.4.66: this only fires on a stripped / custom runtime
// image — the stock OpenPXE container ships `smbclient` from
// the Debian `smbclient` package. The error surfaced on
// v0.4.65 specifically because that release's Dockerfile
// installed `samba` (the server) but not `smbclient` (the
// client CLI). Operators on the stock image should never see
// this; if they do, the fix is to upgrade.
Some(
"smbclient isn't installed in this container. Pull the \
official OpenPXE image v0.4.66 or newer the stock image \
ships smbclient. If you're running a custom build, add the \
Debian `smbclient` package to your runtime stage."
.into(),
)
} else if s.contains("nt_status_logon_failure") || s.contains("logon_failure") {
Some(
"the server rejected the credentials. Double-check the username \
and password many NAS appliances use a separate SMB account \
rather than the system login."
.into(),
)
} else if s.contains("nt_status_access_denied") || s.contains("access_denied") {
Some(
"the credentials worked but the account doesn't have read \
access to this share. Check the share's permissions on the \
server."
.into(),
)
} else if s.contains("nt_status_bad_network_name")
|| s.contains("nt_status_bad_network_path")
|| s.contains("bad_network_name")
{
Some(
"the share name doesn't exist on this server. Enter just the \
share name (e.g. 'isos'), not a path. Use `smbclient -L \
//server` to list shares manually."
.into(),
)
} else if s.contains("connection refused") {
Some(
"the SMB service isn't accepting connections on this port. \
Verify smbd / Samba is running on the server."
.into(),
)
} else if s.contains("connection timed out") || s.contains("no route to host") {
Some(
"the server isn't reachable on this network. Check the IP and \
any firewall in between."
.into(),
)
} else if s.contains("nt_status_network_unreachable") {
Some(
"the server's network is unreachable from this container — \
check the host networking setup."
.into(),
)
} else if s.contains("does not exist") || s.contains("not a directory") {
Some(
"the listed path doesn't exist on the share. Make sure the \
share name is the top-level share, not a sub-path."
.into(),
)
} else if s.contains("session setup failed") {
Some(
"session setup failed — usually a protocol / dialect mismatch. \
Most modern servers speak SMB2/3; very old shares (XP) may \
need legacy support enabled on the server."
.into(),
)
} else {
None
}
}
fn share_id(server: &str, share: &str) -> String {
slugify_str(&format!("{server}-{share}"))
}
/// Normalize a server input: trim, strip scheme prefix the operator
/// may have pasted, and drop trailing slashes. UNC-style `\\server`
/// and `//server` prefixes are also accepted.
fn normalize_server(raw: &str) -> String {
let s = raw.trim();
let s = s
.strip_prefix("smb://")
.or_else(|| s.strip_prefix("cifs://"))
.or_else(|| s.strip_prefix("\\\\"))
.or_else(|| s.strip_prefix("//"))
.unwrap_or(s);
s.trim_end_matches('/').trim_end_matches('\\').to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn share_id_is_stable_and_safe() {
let a = share_id("10.0.0.5", "isos");
let b = share_id("10.0.0.5", "isos");
assert_eq!(a, b);
assert!(!a.contains('/'));
assert!(!a.contains('.'));
}
#[test]
fn normalize_server_strips_url_and_unc_prefixes() {
assert_eq!(normalize_server(" 10.0.0.5 "), "10.0.0.5");
assert_eq!(normalize_server("smb://nas.lan/"), "nas.lan");
assert_eq!(normalize_server("cifs://192.168.1.51"), "192.168.1.51");
assert_eq!(normalize_server("\\\\192.168.1.51\\"), "192.168.1.51");
assert_eq!(normalize_server("//nas.lan//"), "nas.lan");
assert_eq!(normalize_server("nas.lan"), "nas.lan");
}
#[test]
fn hint_for_logon_failure_calls_out_credentials() {
let h = hint_for("session setup failed: NT_STATUS_LOGON_FAILURE").unwrap();
assert!(h.to_lowercase().contains("credentials"));
}
#[test]
fn hint_for_bad_share_name_calls_out_share_lookup() {
let h = hint_for("tree connect failed: NT_STATUS_BAD_NETWORK_NAME").unwrap();
assert!(h.to_lowercase().contains("share"));
}
#[test]
fn hint_for_unknown_is_none() {
assert!(hint_for("some unrelated error text").is_none());
}
#[test]
fn hint_for_missing_smbclient_calls_out_upgrade() {
// The exec-side path sets `stderr` to "smbclient binary not
// found on $PATH" when ENOENT lands.
let h = hint_for("smbclient binary not found on $PATH").unwrap();
assert!(
h.contains("smbclient") && h.to_lowercase().contains("install"),
"expected upgrade/install guidance, got: {h}"
);
// The raw error path on the API side passes the verbatim
// exec error in `error` plus an empty `stderr`. The
// SmbShareError constructor's hint_for fallback checks error
// too, so this pattern needs to translate as well.
let h2 =
hint_for("could not exec smbclient: No such file or directory (os error 2)").unwrap();
assert!(h2.contains("smbclient"));
}
#[test]
fn parse_ls_iso_finds_one_iso_and_skips_directories() {
let out = "\
\tDomain=[WORKGROUP] OS=[Windows] Server=[Samba]\n\
. D 0 Mon May 26 10:00:00 2026\n\
.. D 0 Mon May 26 10:00:00 2026\n\
ubuntu-22.04-desktop.iso A 3650912256 Mon May 26 11:00:00 2026\n\
\n\
\t\t4096 blocks of size 1048576. 1234 blocks available\n\
";
let entries = parse_ls_iso(out);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].filename, "ubuntu-22.04-desktop.iso");
assert_eq!(entries[0].size, 3_650_912_256);
}
#[test]
fn parse_ls_iso_handles_filenames_with_spaces() {
let out = "\
Windows Server 2025.iso A 5000000000 Tue May 27 09:00:00 2026\n\
";
let entries = parse_ls_iso(out);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].filename, "Windows Server 2025.iso");
assert_eq!(entries[0].size, 5_000_000_000);
}
#[test]
fn parse_ls_iso_skips_non_iso_files() {
let out = "\
readme.txt A 100 Tue May 27 09:00:00 2026\n\
archive.zip A 5000 Tue May 27 09:00:00 2026\n\
";
let entries = parse_ls_iso(out);
assert!(entries.is_empty());
}
#[test]
fn add_request_requires_username_when_not_guest() {
// We can't easily test the add() path against a real SMB
// server in unit tests, but we can confirm the validation
// logic at least serializes the request shape we expect. The
// actual auth check happens in add() itself which we cover in
// integration tests against a stub server.
let req = SmbAddRequest {
server: "10.0.0.5".into(),
share: "isos".into(),
username: None,
password: None,
guest: false,
port: None,
};
// No SmbShareManager here — we just check the field shape
// matches what UI submits.
assert!(!req.guest);
assert!(req.username.is_none());
}
}
+79 -533
View File
@@ -3,8 +3,8 @@
use crate::entry::{BootEntry, BootKind, KernelArgs}; use crate::entry::{BootEntry, BootKind, KernelArgs};
use crate::introspect::{introspect, DistroFamily, IntrospectionReport}; use crate::introspect::{introspect, DistroFamily, IntrospectionReport};
use bytes::Bytes; use bytes::Bytes;
use openpxe_core::{Error, Result};
use parking_lot::RwLock; use parking_lot::RwLock;
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;
@@ -15,68 +15,20 @@ use tokio::io::AsyncWriteExt;
/// Where the bytes for an ISO actually live. /// Where the bytes for an ISO actually live.
/// ///
/// `Local` — uploaded ISO, sits at `<iso_dir>/<id>.iso`. /// The default is `Local` — uploaded ISOs sit in `<iso_dir>/<id>.iso`.
/// `Smb` (v0.4.65) — remote SMB share, streamed via Samba's /// `Nfs` entries point at a file inside a remote share that the
/// userspace `smbclient` CLI subprocess. No kernel mount, no local /// `NfsManager` is keeping mounted. We resolve the on-disk path lazily
/// cache. Sequential whole-file streaming; HTTP Range requests /// in [`IsoStore::iso_path_for`] using the `nfs_root` set at startup.
/// return 416.
/// `Nfs` (v0.4.67) — remote NFSv3 share, streamed via the pure-Rust
/// `nfs3_client` crate (in-process, no subprocess). Same "works in
/// any container" property as SMB, plus Range requests work because
/// NFSv3 READ3 takes an explicit offset.
/// `Sftp` (v0.5.5) — remote SFTP-over-SSH share, streamed via the
/// pure-Rust `russh` + `russh-sftp` crates (in-process). Like NFS it
/// supports HTTP Range requests because SFTP opens a seekable file
/// handle (`SSH_FXP_READ` at offset).
#[derive(Debug, Clone, Default, 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] #[default]
Local, Local,
/// v0.4.65: SMB via userspace `smbclient` works in any container.
Smb {
share_id: String,
/// Filename at the share root. We don't support nested paths
/// in v0.4.65; ISOs live at the top of the share.
relative_path: String,
},
/// v0.4.67: NFSv3 via the in-process `nfs3_client` crate.
Nfs { Nfs {
share_id: String, mount_id: String,
/// Filename at the export root. /// Path relative to the mount point — typically just the filename.
relative_path: String, relative_path: String,
}, },
/// v0.5.5: SFTP-over-SSH via the in-process `russh` + `russh-sftp`
/// crates.
Sftp {
share_id: String,
/// Filename at the export root.
relative_path: String,
},
}
/// Where the ISO lands in the PXE menu hierarchy.
///
/// Auto-detected family (Debian, Windows, …) still drives BIOS/UEFI
/// behaviour and per-entry boot args, but the *menu placement* is
/// operator-controlled — an operator who's uploaded a TinyCore live ISO
/// to use as a recovery shim, or a SystemRescue image, can flip its
/// category to `Tools` so it lands next to memtest/shell instead of
/// under Linux Installers.
///
/// Old `meta.json` files without this field deserialize as `Os`, which
/// matches v0.4.1 behaviour (everything goes under OS Installers).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IsoCategory {
/// "OS Installer" — routed via the auto-detected family into the
/// Linux / Windows installer submenus.
#[default]
Os,
/// "Tool" — surfaced under the Tools menu next to memtest, shell,
/// NIC info, etc. Family detection still decides BIOS/UEFI vs
/// wimboot vs sanboot at boot time.
Tools,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -96,29 +48,6 @@ pub struct IsoMeta {
/// Old `meta.json` files without this field deserialize as `Local`. /// Old `meta.json` files without this field deserialize as `Local`.
#[serde(default)] #[serde(default)]
pub source: IsoSource, pub source: IsoSource,
/// Optional bcrypt hash of an operator-set password. When present,
/// `/boot/<entry>.ipxe` returns a `read --secret` prompt instead of
/// the boot script until the client chains back with the correct
/// `?token=...`. We never store, log, or transmit the plaintext.
/// Skipped on serialize when None to keep meta.json clean for
/// the common no-password case.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password_hash: Option<String>,
/// Where the ISO sits in the PXE menu hierarchy — operator-controlled,
/// not driven by family detection. Defaults to [`IsoCategory::Os`].
#[serde(default)]
pub category: IsoCategory,
}
impl IsoMeta {
/// Convenience predicate the HTTP layer + UI can both use.
#[must_use]
pub fn is_password_protected(&self) -> bool {
self.password_hash
.as_deref()
.map(str::trim)
.is_some_and(|h| !h.is_empty())
}
} }
pub struct UploadHandle { pub struct UploadHandle {
@@ -165,8 +94,6 @@ impl UploadHandle {
introspection, introspection,
boot_entries, boot_entries,
source: IsoSource::Local, source: IsoSource::Local,
password_hash: None,
category: IsoCategory::default(),
}; };
store.persist_meta(&meta).await?; store.persist_meta(&meta).await?;
store.insert(meta.clone()); store.insert(meta.clone());
@@ -188,6 +115,10 @@ struct Inner {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct IsoStore { pub struct IsoStore {
iso_dir: Arc<PathBuf>, iso_dir: Arc<PathBuf>,
/// Where NFS mounts land on disk. Set at startup via
/// [`IsoStore::set_nfs_root`]; required for resolving any
/// `IsoSource::Nfs` entry.
nfs_root: Arc<RwLock<Option<PathBuf>>>,
inner: Arc<RwLock<Inner>>, inner: Arc<RwLock<Inner>>,
} }
@@ -195,10 +126,17 @@ impl IsoStore {
pub fn new(iso_dir: PathBuf) -> Self { pub fn new(iso_dir: PathBuf) -> Self {
Self { Self {
iso_dir: Arc::new(iso_dir), iso_dir: Arc::new(iso_dir),
nfs_root: Arc::new(RwLock::new(None)),
inner: Arc::new(RwLock::new(Inner::default())), inner: Arc::new(RwLock::new(Inner::default())),
} }
} }
/// Tell the store where NFS mounts live. Without this set,
/// `IsoSource::Nfs` entries cannot be resolved to a file path.
pub fn set_nfs_root(&self, root: PathBuf) {
*self.nfs_root.write() = Some(root);
}
pub async fn ensure_dirs(&self) -> Result<()> { pub async fn ensure_dirs(&self) -> Result<()> {
tokio::fs::create_dir_all(self.iso_dir.as_path()).await?; tokio::fs::create_dir_all(self.iso_dir.as_path()).await?;
Ok(()) Ok(())
@@ -212,9 +150,7 @@ impl IsoStore {
let mut entries = tokio::fs::read_dir(self.iso_dir.as_path()).await?; let mut entries = tokio::fs::read_dir(self.iso_dir.as_path()).await?;
while let Some(e) = entries.next_entry().await? { while let Some(e) = entries.next_entry().await? {
let p = e.path(); let p = e.path();
if p.extension().and_then(|s| s.to_str()) != Some("json") { if p.extension().and_then(|s| s.to_str()) != Some("json") { continue; }
continue;
}
if !p if !p
.file_name() .file_name()
.and_then(|s| s.to_str()) .and_then(|s| s.to_str())
@@ -223,8 +159,7 @@ impl IsoStore {
continue; continue;
} }
if let Ok(text) = tokio::fs::read_to_string(&p).await { if let Ok(text) = tokio::fs::read_to_string(&p).await {
if let Ok(mut meta) = serde_json::from_str::<IsoMeta>(&text) { if let Ok(meta) = serde_json::from_str::<IsoMeta>(&text) {
self.reintrospect_if_stale(&mut meta).await;
self.insert(meta); self.insert(meta);
} }
} }
@@ -232,46 +167,6 @@ impl IsoStore {
Ok(()) Ok(())
} }
/// v0.5.9: re-run introspection on a *local* ISO whose persisted report
/// predates the current logic. ISOs uploaded by an older binary carry a
/// stale family/boot profile — most visibly a Windows 11 ISO tagged
/// `Unknown` before the UDF/El-Torito detection landed, which then shows
/// as "won't boot" forever. Re-probing on startup fixes them in place,
/// no delete-and-re-upload. Bounded: only `Local` sources (we have the
/// bytes locally) below [`introspect::INTROSPECT_REV`], so it runs at
/// most once per ISO per upgrade. The probe reads up to ~64 MiB, so we
/// push it onto the blocking pool to keep the async runtime responsive.
async fn reintrospect_if_stale(&self, meta: &mut IsoMeta) {
if !matches!(meta.source, IsoSource::Local)
|| meta.introspection.introspect_rev >= crate::introspect::INTROSPECT_REV
{
return;
}
let path = self.iso_path(&meta.id);
if !path.exists() {
return;
}
let Ok(fresh) = tokio::task::spawn_blocking(move || introspect(&path)).await else {
tracing::warn!(target: "openpxe::iso", id = %meta.id, "re-introspect task failed");
return;
};
let before = meta.introspection.family;
meta.introspection = fresh;
meta.boot_entries = generate_boot_entries(&meta.id, &meta.filename, &meta.introspection);
if let Err(e) = self.persist_meta(meta).await {
tracing::warn!(target: "openpxe::iso", id = %meta.id, "re-introspect persist: {e}");
return;
}
tracing::info!(
target: "openpxe::iso",
id = %meta.id,
from = ?before,
to = ?meta.introspection.family,
el_torito = meta.introspection.el_torito,
"re-introspected stale ISO metadata"
);
}
fn insert(&self, meta: IsoMeta) { fn insert(&self, meta: IsoMeta) {
self.inner.write().isos.insert(meta.id.clone(), meta); self.inner.write().isos.insert(meta.id.clone(), meta);
} }
@@ -299,9 +194,6 @@ impl IsoStore {
return Err(Error::Invalid(format!("iso '{id}' already exists"))); return Err(Error::Invalid(format!("iso '{id}' already exists")));
} }
let partial_path = self.iso_dir.join(format!("{id}.partial")); let partial_path = self.iso_dir.join(format!("{id}.partial"));
if partial_path.exists() {
return Err(Error::Invalid(format!("iso '{id}' is already uploading")));
}
let file = tokio::fs::File::create(&partial_path).await?; let file = tokio::fs::File::create(&partial_path).await?;
Ok(UploadHandle { Ok(UploadHandle {
id, id,
@@ -335,48 +227,36 @@ impl IsoStore {
self.inner.read().isos.get(id).cloned() self.inner.read().isos.get(id).cloned()
} }
/// Resolve an ISO id to its on-disk path, if any. For local /// Resolve an ISO id to its on-disk path, if any. For local entries
/// (uploaded) ISOs this is `<iso_dir>/<id>.iso`. For SMB-sourced /// this is `<iso_dir>/<id>.iso`; for NFS entries it's
/// ISOs there is no on-disk path — the HTTP handler must stream /// `<nfs_root>/<mount_id>/<relative_path>`. Returns None if the file
/// via `SmbShareManager::stream_iso` instead. Returns `None` for /// is missing or the source isn't resolvable (e.g. NFS share
/// SMB sources or when the file is missing. /// unmounted).
pub fn iso_path_for(&self, id: &str) -> Option<PathBuf> { pub fn iso_path_for(&self, id: &str) -> Option<PathBuf> {
let meta = self.get(id)?; let meta = self.get(id)?;
self.local_path(&meta) let path = match &meta.source {
} IsoSource::Local => self.iso_path(id),
IsoSource::Nfs {
/// Same resolution as [`Self::iso_path_for`], but for a meta the mount_id,
/// caller already holds — skips the second registry lock + deep relative_path,
/// clone, which matters on the per-range-request ISO serving path } => {
/// (a sanboot install issues hundreds of those). let root = self.nfs_root.read().clone()?;
#[must_use] root.join(mount_id).join(relative_path)
pub fn local_path(&self, meta: &IsoMeta) -> Option<PathBuf> {
match &meta.source {
IsoSource::Local => {
let path = self.iso_path(&meta.id);
if path.exists() {
Some(path)
} else {
None
}
} }
// SMB, NFS, and SFTP sources have no local path — they're };
// streamed in-process. Callers must inspect the source if path.exists() {
// kind first and dispatch to the appropriate share Some(path)
// manager. } else {
IsoSource::Smb { .. } | IsoSource::Nfs { .. } | IsoSource::Sftp { .. } => None, None
} }
} }
/// Delete an ISO and its sidecar metadata. Only acts on local /// Delete an ISO and its sidecar metadata. Only acts on local ISOs;
/// (uploaded) ISOs; for SMB-backed ISOs the operator must remove /// for NFS-backed ISOs the operator must remove the file from the
/// the file from the share or unregister the share entirely. /// share or unmount the NFS share entirely.
pub async fn delete(&self, id: &str) -> Result<()> { pub async fn delete(&self, id: &str) -> Result<()> {
let meta = self.get(id); let meta = self.get(id);
let is_local = matches!( let is_local = matches!(meta.as_ref().map(|m| &m.source), Some(IsoSource::Local) | None);
meta.as_ref().map(|m| &m.source),
Some(IsoSource::Local) | None
);
if is_local { if is_local {
let iso = self.iso_path(id); let iso = self.iso_path(id);
let meta_path = self.meta_path(id); let meta_path = self.meta_path(id);
@@ -387,10 +267,10 @@ impl IsoStore {
Ok(()) Ok(())
} }
/// Register an externally-sourced ISO (SMB share, etc.). Used by /// Register an externally-sourced ISO (e.g. NFS-mounted). Used by
/// `SmbShareManager` after listing a share. We do **not** persist /// `NfsManager` after walking a freshly-mounted share. We do **not**
/// a `meta.json` on disk for these — the source of truth is the /// persist a `meta.json` on disk for these — the source of truth is
/// share itself, and the manager re-scans on startup. /// the share itself, and the NFS manager re-scans on startup.
pub fn register_external( pub fn register_external(
&self, &self,
id: String, id: String,
@@ -409,155 +289,19 @@ impl IsoStore {
introspection, introspection,
boot_entries, boot_entries,
source, source,
password_hash: None,
category: IsoCategory::default(),
}; };
self.inner.write().isos.insert(id, meta); self.inner.write().isos.insert(id, meta);
} }
/// Drop every entry that belongs to `share_id`. Used by the SMB /// Drop every entry that belongs to `mount_id`. Used by the NFS
/// and NFS share managers when an operator removes a share, or /// manager when an operator removes a share, or before re-scanning
/// before re-scanning to clean out stale entries. The same id /// to clean out stale entries.
/// space serves both protocols — share ids are slugified from pub fn drop_external_source(&self, mount_id: &str) {
/// `server+share` (SMB) or `server+export` (NFS) and the
/// protocol-specific prefix prevents collisions.
pub fn drop_external_source(&self, share_id: &str) {
let mut g = self.inner.write(); let mut g = self.inner.write();
g.isos.retain(|_, m| match &m.source { g.isos.retain(|_, m| {
IsoSource::Smb { share_id: sid, .. } !matches!(&m.source, IsoSource::Nfs { mount_id: mid, .. } if mid == mount_id)
| IsoSource::Nfs { share_id: sid, .. }
| IsoSource::Sftp { share_id: sid, .. } => sid != share_id,
IsoSource::Local => true,
}); });
} }
/// Set or clear an ISO's boot password.
///
/// `Some("plaintext")` hashes via bcrypt (cost 10 — fast enough for
/// an interactive iPXE prompt, slow enough to be hostile to brute
/// force on a leaked meta.json) and persists.
///
/// `None` removes the password — the next /boot/<id>.ipxe request
/// returns the script directly without a prompt.
///
/// We never store, log, or transmit the plaintext.
pub async fn set_password(&self, id: &str, password: Option<&str>) -> Result<()> {
let new_hash = match password {
None => None,
Some(pw) => {
let pw = pw.trim();
if pw.is_empty() {
None
} else {
let h = bcrypt::hash(pw, bcrypt::DEFAULT_COST)
.map_err(|e| Error::Other(e.into()))?;
Some(h)
}
}
};
// Update in-memory + grab a clone for persistence outside the lock.
let updated = {
let mut g = self.inner.write();
let m = g
.isos
.get_mut(id)
.ok_or_else(|| Error::Invalid(format!("no such iso '{id}'")))?;
m.password_hash = new_hash;
m.clone()
};
// NFS-sourced ISOs have no on-disk meta.json — skip persistence
// for them (the password lives in memory until the manager
// re-scans the share, then it's gone). Document this in the API
// handler so the operator knows.
if matches!(updated.source, IsoSource::Local) {
self.persist_meta(&updated).await?;
}
Ok(())
}
/// Flip an ISO's menu category. Persists to `meta.json` for local
/// ISOs; NFS-sourced ISOs keep the change in memory only (the next
/// re-scan would overwrite it anyway).
pub async fn set_category(&self, id: &str, category: IsoCategory) -> Result<IsoMeta> {
let updated = {
let mut g = self.inner.write();
let m = g
.isos
.get_mut(id)
.ok_or_else(|| Error::Invalid(format!("no such iso '{id}'")))?;
m.category = category;
m.clone()
};
if matches!(updated.source, IsoSource::Local) {
self.persist_meta(&updated).await?;
}
Ok(updated)
}
/// Absolute path to the directory holding local ISO uploads. Used
/// by the HTTP layer for the disk-space endpoint — the volume that
/// hosts this directory is what runs out of room first.
#[must_use]
pub fn iso_dir(&self) -> PathBuf {
self.iso_dir.as_path().to_path_buf()
}
/// `(total_bytes, available_bytes)` for the filesystem hosting the
/// ISO directory. Returns `None` if `statvfs` fails (read-only
/// filesystem with no quota, mount disappeared, …) — callers
/// should treat that as "unknown" rather than zero.
///
/// Lives here rather than the HTTP crate because `http-api`'s
/// `#![forbid(unsafe_code)]` rules out the libc FFI directly, and
/// because this is naturally an `IsoStore` question — the volume
/// of interest is whatever's hosting the iso dir.
#[must_use]
pub fn disk_usage(&self) -> Option<(u64, u64)> {
disk_usage_for(self.iso_dir.as_path())
}
/// Verify a candidate password against the stored bcrypt hash.
/// Returns:
/// - `Ok(true)` — match (or the ISO has no password set; boot is open)
/// - `Ok(false)` — mismatch
/// - `Err(_)` — id not found, or bcrypt error
pub fn verify_password(&self, id: &str, candidate: &str) -> Result<bool> {
let meta = self
.get(id)
.ok_or_else(|| Error::Invalid(format!("no such iso '{id}'")))?;
let Some(hash) = meta.password_hash else {
return Ok(true); // no password set — anyone can boot
};
bcrypt::verify(candidate, &hash).map_err(|e| Error::Other(e.into()))
}
}
/// Resolve `(total, available)` bytes for the filesystem hosting `path`.
/// Returns `None` if `statvfs` fails.
#[allow(unsafe_code)]
fn disk_usage_for(path: &std::path::Path) -> Option<(u64, u64)> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let c = CString::new(path.as_os_str().as_bytes()).ok()?;
// SAFETY: `statvfs` is repr(C); a zeroed value is a valid initial
// state per POSIX. The FFI call writes every field we then read.
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
// SAFETY: `c` is a NUL-terminated C string pointing into a stack
// CString that outlives this call; `&mut stat` is a unique aligned
// pointer to a stack-local `statvfs`. The kernel writes through
// it but does not retain the pointer past return.
let rc = unsafe { libc::statvfs(c.as_ptr(), &raw mut stat) };
if rc != 0 {
return None;
}
// Use f_frsize (fundamental block size). f_bsize is "preferred I/O
// block" and doesn't always match the unit f_blocks is denominated
// in — on some BSDs it would over-report by a factor of 8.
let frsize = stat.f_frsize as u64;
let total = stat.f_blocks as u64 * frsize;
let avail = stat.f_bavail as u64 * frsize;
Some((total, avail))
} }
fn slugify(filename: &str) -> String { fn slugify(filename: &str) -> String {
@@ -602,79 +346,46 @@ pub fn generate_boot_entries_for(
/// Build `BootEntry`s from the introspection report. URLs are relative — /// Build `BootEntry`s from the introspection report. URLs are relative —
/// the HTTP layer rewrites them with the public base URL per request. /// the HTTP layer rewrites them with the public base URL per request.
fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> Vec<BootEntry> { fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> Vec<BootEntry> {
let title = r let title = r.volume_label.clone().unwrap_or_else(|| filename.to_string());
.volume_label
.clone()
.unwrap_or_else(|| filename.to_string());
match r.family { match r.family {
DistroFamily::WindowsPe => { DistroFamily::WindowsPe if r.has_boot_wim => {
// v0.5.8: boot Windows directly via iPXE HTTP sanboot. iPXE // Standard wimboot chain. Paths are in-ISO; the HTTP layer maps
// exposes the raw ISO as an emulated CD backed by on-demand // `iso/<id>/<path>` to on-disk extraction via ISO9660 lookup.
// HTTP range reads, and Windows Setup boots from it. This let base = format!("iso/{id}");
// replaces the old wimboot+SMB chain, which (a) needed an SMB
// server the host often can't provide (port 445 collisions),
// (b) served in-ISO files via an ISO9660 lookup that failed on
// UDF-only Windows 11 ISOs, and (c) required an operator
// toggle. sanboot needs none of that — just the HTTP port,
// which works in any environment. The unmodified, stock ISO is
// served at iso/<id>.iso; nothing is injected into Windows.
vec![BootEntry { vec![BootEntry {
id: format!("{id}-windows"), id: format!("{id}-winpe"),
title: format!("{title} (Windows)"), title: format!("{title} (Windows / wimboot)"),
kind: BootKind::SanBootIso { kind: BootKind::Wimboot {
iso_url: format!("iso/{id}.iso"), wimboot_url: "ipxe/wimboot".to_string(),
files: vec![
("bootmgr".into(), format!("{base}/bootmgr")),
("bootmgr.efi".into(), format!("{base}/bootmgr.efi")),
("bcd".into(), format!("{base}/boot/bcd")),
("boot.sdi".into(), format!("{base}/boot/boot.sdi")),
("boot.wim".into(), format!("{base}/sources/boot.wim")),
],
}, },
}] }]
} }
fam if r.kernel_path.is_some() => { fam if r.kernel_path.is_some() => {
let base = format!("iso/{id}"); let base = format!("iso/{id}");
let kernel_url = format!("{base}{}", r.kernel_path.as_deref().unwrap_or("")); let kernel_url = format!("{base}{}", r.kernel_path.as_deref().unwrap_or(""));
let initrd_urls = r let initrd_urls = r.initrd_paths.iter().map(|p| format!("{base}{p}")).collect();
.initrd_paths let args = KernelArgs { cmdline: linux_cmdline(fam, id) };
.iter()
.map(|p| format!("{base}{p}"))
.collect();
let args = KernelArgs {
cmdline: linux_cmdline(fam, id),
};
vec![BootEntry { vec![BootEntry {
id: format!("{id}-linux"), id: format!("{id}-linux"),
title, title,
kind: BootKind::LinuxKernel { kind: BootKind::LinuxKernel { kernel_url, initrd_urls, args },
kernel_url,
initrd_urls,
args,
},
}] }]
} }
_ => { _ => {
// No Windows-install media and no Linux kernel/initrd. Decide // Last-resort SAN boot. Won't work for large modern ISOs, but
// whether the ISO is bootable at all (v0.6.0): // lets the ISO at least appear in the menu.
// * `el_torito` — it carries a boot catalog, so iPXE sanboots vec![BootEntry {
// the raw image as an emulated CD: BSDs, ESXi/VMvisor id: format!("{id}-sanboot"),
// installers, firmware tools, custom spins. The emulated CD title: format!("{title} (SAN boot — may fail for >1GiB ISOs)"),
// is backed by HTTP range reads, so ISO size is a non-issue kind: BootKind::SanBootIso { iso_url: format!("iso/{id}.iso") },
// (this is the same path Windows uses since v0.5.8) — hence }]
// no more "may fail for >1GiB ISOs" disclaimer.
// * `introspect_rev == 0` — a remote-share ISO we couldn't
// introspect (SMB/NFS/SFTP listings don't seek into the ISO).
// Offer sanboot optimistically rather than hide a
// likely-bootable installer.
// Otherwise it's a local image we *did* introspect and found to
// carry no boot catalog — a data/appliance ISO (e.g. a VMware
// vCenter Server Appliance bundle). It genuinely cannot boot, so
// we expose no menu entry; the dashboard flags it instead.
if r.el_torito || r.introspect_rev == 0 {
vec![BootEntry {
id: format!("{id}-sanboot"),
title,
kind: BootKind::SanBootIso {
iso_url: format!("iso/{id}.iso"),
},
}]
} else {
Vec::new()
}
} }
} }
} }
@@ -683,17 +394,8 @@ fn linux_cmdline(family: DistroFamily, id: &str) -> String {
// The HTTP layer resolves `${base-url}` at render time. // The HTTP layer resolves `${base-url}` at render time.
let iso_url = format!("${{base-url}}/iso/{id}.iso"); let iso_url = format!("${{base-url}}/iso/{id}.iso");
match family { match family {
// VMware-UEFI fix (v0.4.5, matching Bootimus v0.1.67's Casper
// patch): drop `netboot=url url=… ---` in favour of the
// canonical Casper option `iso-url=` and add `ds=nocloud` so
// cloud-init / subiquity (live-server) doesn't stall waiting on
// a metadata datasource that doesn't exist in PXE. Without
// `ds=nocloud`, Ubuntu live-server / Mint / Pop!_OS / elementary
// ISOs would boot fine on bare-metal UEFI but hang at "cloud-init
// running" on VMware-UEFI guests because the vmxnet3 driver's
// late-init upsets cloud-init's network probe.
DistroFamily::DebianUbuntu => format!( DistroFamily::DebianUbuntu => format!(
"boot=casper initrd=initrd ds=nocloud ip=dhcp iso-url={iso_url}" "boot=casper netboot=url url={iso_url} ip=dhcp ---"
), ),
DistroFamily::RhelFedora => format!( DistroFamily::RhelFedora => format!(
"inst.repo={iso_url} inst.stage2={iso_url} ip=dhcp" "inst.repo={iso_url} inst.stage2={iso_url} ip=dhcp"
@@ -714,8 +416,6 @@ fn linux_cmdline(family: DistroFamily, id: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::introspect::{DistroFamily, IntrospectionReport};
use tempfile::tempdir;
#[test] #[test]
fn slugify_basic() { fn slugify_basic() {
@@ -727,158 +427,4 @@ mod tests {
// If a path sneaks in, file_stem strips the directory — OK, not a hazard. // If a path sneaks in, file_stem strips the directory — OK, not a hazard.
assert_eq!(slugify("/etc/passwd"), "passwd"); assert_eq!(slugify("/etc/passwd"), "passwd");
} }
#[test]
fn casper_cmdline_vmware_uefi_safe() {
// v0.4.5 regression guard: the Debian/Ubuntu cmdline must use
// the canonical Casper `iso-url=` option and include
// `ds=nocloud` so VMware-UEFI guests don't hang at "cloud-init
// running" waiting on a metadata datasource that PXE can't
// provide. The legacy `netboot=url url=… ---` form is gone for
// good.
let s = linux_cmdline(DistroFamily::DebianUbuntu, "ubuntu-24-04");
assert!(s.contains("boot=casper"), "{s}");
assert!(
s.contains("iso-url=${base-url}/iso/ubuntu-24-04.iso"),
"{s}"
);
assert!(s.contains("ds=nocloud"), "{s}");
assert!(s.contains("ip=dhcp"), "{s}");
assert!(!s.contains("netboot=url"), "legacy option leaked: {s}");
assert!(!s.contains(" --- "), "stray ---: {s}");
}
#[test]
fn boot_entries_respect_el_torito_and_source() {
use crate::introspect::INTROSPECT_REV;
// ESXi / VMvisor installer shape: bootable (carries an El Torito
// catalog) but not classifiable as Windows or Linux. Must yield a
// single sanboot entry so it's selectable + boots via emulated CD.
let esxi = IntrospectionReport {
family: DistroFamily::Unknown,
volume_label: Some("ESXI-7.0U3".into()),
el_torito: true,
introspect_rev: INTROSPECT_REV,
..Default::default()
};
let e = generate_boot_entries("esxi", "VMware-VMvisor-Installer-7.0U3n.iso", &esxi);
assert_eq!(e.len(), 1, "ESXi should get exactly one boot entry");
assert!(matches!(e[0].kind, BootKind::SanBootIso { .. }));
// Clean title — no stale ">1GiB may fail" disclaimer.
assert!(!e[0].title.contains("may fail"), "title: {}", e[0].title);
// VCSA / data-appliance shape: locally introspected (rev set), no
// boot catalog, not Windows/Linux. Genuinely unbootable → no entry,
// so it stays out of the iPXE menu (the dashboard flags it instead).
let vcsa = IntrospectionReport {
family: DistroFamily::Unknown,
el_torito: false,
introspect_rev: INTROSPECT_REV,
..Default::default()
};
assert!(
generate_boot_entries("vcsa", "VMware-VCSA-all-8.0.iso", &vcsa).is_empty(),
"data/appliance ISO must produce no boot entry"
);
// Remote-share ISO: never introspected (rev 0, no random access over
// SMB/NFS/SFTP). Assume bootable and offer sanboot rather than hide a
// likely-bootable installer.
let remote = IntrospectionReport::default();
let r = generate_boot_entries("remote", "unknown-remote.iso", &remote);
assert_eq!(r.len(), 1, "remote (uninspected) ISO keeps a sanboot entry");
assert!(matches!(r[0].kind, BootKind::SanBootIso { .. }));
}
fn fake_meta(id: &str) -> IsoMeta {
IsoMeta {
id: id.into(),
filename: format!("{id}.iso"),
size_bytes: 0,
sha256_hex: None,
uploaded_at: OffsetDateTime::now_utc(),
introspection: IntrospectionReport::default(),
boot_entries: vec![],
source: IsoSource::Local,
password_hash: None,
category: IsoCategory::default(),
}
}
#[tokio::test]
async fn password_round_trip_set_verify_clear() {
let dir = tempdir().unwrap();
let store = IsoStore::new(dir.path().to_path_buf());
store.ensure_dirs().await.unwrap();
store
.inner
.write()
.isos
.insert("alpha".into(), fake_meta("alpha"));
// No password set — verify_password returns Ok(true) for any input.
assert!(store.verify_password("alpha", "anything").unwrap());
assert!(!store.get("alpha").unwrap().is_password_protected());
// Set a password.
store.set_password("alpha", Some("hunter2")).await.unwrap();
let m = store.get("alpha").unwrap();
assert!(m.is_password_protected());
assert!(m.password_hash.unwrap().starts_with("$2"));
// Verify correct + wrong.
assert!(store.verify_password("alpha", "hunter2").unwrap());
assert!(!store.verify_password("alpha", "wrong").unwrap());
assert!(!store.verify_password("alpha", "").unwrap());
// Clear by passing None or an empty string.
store.set_password("alpha", None).await.unwrap();
assert!(!store.get("alpha").unwrap().is_password_protected());
store.set_password("alpha", Some("again")).await.unwrap();
store.set_password("alpha", Some(" ")).await.unwrap();
assert!(!store.get("alpha").unwrap().is_password_protected());
}
#[tokio::test]
async fn set_password_for_unknown_id_errors() {
let dir = tempdir().unwrap();
let store = IsoStore::new(dir.path().to_path_buf());
store.ensure_dirs().await.unwrap();
let r = store.set_password("does-not-exist", Some("pw")).await;
assert!(matches!(r, Err(Error::Invalid(_))));
}
#[tokio::test]
async fn begin_upload_rejects_existing_partial_file() {
let dir = tempdir().unwrap();
let store = IsoStore::new(dir.path().to_path_buf());
store.ensure_dirs().await.unwrap();
tokio::fs::write(dir.path().join("ubuntu.partial"), b"in-flight")
.await
.unwrap();
let r = store.begin_upload("ubuntu.iso").await;
assert!(matches!(r, Err(Error::Invalid(_))));
}
#[tokio::test]
async fn password_persists_via_meta_json_for_local_isos() {
// Hash makes it onto disk so it survives a restart.
let dir = tempdir().unwrap();
let store = IsoStore::new(dir.path().to_path_buf());
store.ensure_dirs().await.unwrap();
let meta = fake_meta("alpha");
store.persist_meta(&meta).await.unwrap();
store.insert(meta);
store.set_password("alpha", Some("s3cret")).await.unwrap();
// Re-load from disk and confirm the hash came back.
let store2 = IsoStore::new(dir.path().to_path_buf());
store2.load_from_disk().await.unwrap();
let reloaded = store2.get("alpha").expect("reloaded");
assert!(reloaded.is_password_protected());
assert!(store2.verify_password("alpha", "s3cret").unwrap());
assert!(!store2.verify_password("alpha", "wrong").unwrap());
}
} }
-412
View File
@@ -1,412 +0,0 @@
//! Unattended-install answer-file store (v0.5.2).
//!
//! Operators upload the answer file their installer expects — a RHEL/
//! Fedora **Kickstart**, a Debian **Preseed**, an Ubuntu **Autoinstall**
//! cloud-init user-data, or a Windows **answer file** (`autounattend.xml`)
//! — and OpenPXE serves it on demand to the booting machine. Files live
//! in their own directory (`<unattended_dir>/`), deliberately *not* under
//! `iso_dir`, so they never appear in the ISO listing or the PXE menu.
//!
//! Storage mirrors [`crate::store::IsoStore`]: in-memory map authoritative
//! for the process, sidecar `*.meta.json` on disk is the source of truth on
//! restart. The raw answer file sits beside it as `<id>.file`.
//!
//! Templating is applied at *serve* time, not store time — see
//! [`render_template`]. The stored bytes are exactly what the operator
//! uploaded; per-host hostname/IP/MAC values are substituted into a copy
//! when the file is fetched for a specific client.
use crate::store::slugify_str;
use openpxe_core::{Error, Result};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use time::OffsetDateTime;
/// Disk + memory cap for one answer file. Kickstarts/preseeds/cloud-init
/// configs are a few KB; 1 MiB is a comfortable ceiling that still bounds
/// abuse.
pub const MAX_UNATTENDED_BYTES: usize = 1024 * 1024;
/// Which installer the answer file targets. Drives the kernel-argument
/// injection in the boot chain.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UnattendedKind {
/// RHEL / Fedora / CentOS / AlmaLinux / Rocky — `inst.ks=<url>`.
Kickstart,
/// Debian / older Ubuntu — `auto=true priority=critical url=<url>`.
Preseed,
/// Ubuntu 20.04+ Subiquity autoinstall — cloud-init NoCloud:
/// `autoinstall ds=nocloud-net;s=<url>/`.
Autoinstall,
/// Windows Setup answer file (`autounattend.xml`). Served, not
/// auto-injected (Windows reads it from media/USB, not a kernel arg).
AnswerFile,
/// Couldn't classify — stored + served, no auto-injection.
#[default]
Unknown,
}
impl UnattendedKind {
#[must_use]
pub fn label(self) -> &'static str {
match self {
UnattendedKind::Kickstart => "Kickstart",
UnattendedKind::Preseed => "Preseed",
UnattendedKind::Autoinstall => "Autoinstall",
UnattendedKind::AnswerFile => "Answer file",
UnattendedKind::Unknown => "Unknown",
}
}
}
/// Lowercase file extension (no dot), or `None` if there isn't one.
fn ext_lower(filename: &str) -> Option<String> {
std::path::Path::new(filename)
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
}
/// Classify an upload from its filename + a peek at its content. Best
/// effort: extension first, then a content sniff to disambiguate the
/// `.cfg` case (both Kickstart and Preseed use it).
#[must_use]
pub fn classify(filename: &str, content: &[u8]) -> UnattendedKind {
let lower_name = filename.to_ascii_lowercase();
let ext = ext_lower(filename);
let text = String::from_utf8_lossy(&content[..content.len().min(8192)]);
let looks_preseed = text.contains("d-i ") || text.contains("preseed/");
let looks_kickstart = text.contains("%packages")
|| text.contains("\nlang ")
|| text.contains("\nkeyboard ")
|| text.contains("bootloader --")
|| text.starts_with("install");
let looks_cloud_init = text.contains("autoinstall")
|| text.contains("#cloud-config")
|| text.contains("version: 1");
match ext.as_deref() {
Some("ks") => return UnattendedKind::Kickstart,
Some("seed") => return UnattendedKind::Preseed,
Some("xml") => return UnattendedKind::AnswerFile,
Some("yaml" | "yml") => return UnattendedKind::Autoinstall,
Some("cfg") => {
return if looks_kickstart && !looks_preseed {
UnattendedKind::Kickstart
} else {
UnattendedKind::Preseed
};
}
_ => {}
}
if lower_name == "user-data" {
return UnattendedKind::Autoinstall;
}
// No recognised extension — fall back to content sniffing.
if looks_cloud_init {
UnattendedKind::Autoinstall
} else if looks_kickstart {
UnattendedKind::Kickstart
} else if looks_preseed {
UnattendedKind::Preseed
} else {
UnattendedKind::Unknown
}
}
/// True if the filename carries an extension we accept for upload. We
/// also accept the bare `user-data` name (cloud-init NoCloud convention).
#[must_use]
pub fn is_accepted_filename(filename: &str) -> bool {
if filename.trim().eq_ignore_ascii_case("user-data") {
return true;
}
matches!(
ext_lower(filename).as_deref(),
Some("ks" | "cfg" | "seed" | "yaml" | "yml" | "xml")
)
}
/// Sidecar metadata for a stored answer file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnattendedMeta {
/// URL-safe slug, unique within the store.
pub id: String,
/// Original upload filename, shown in the UI.
pub filename: String,
pub kind: UnattendedKind,
pub size_bytes: u64,
#[serde(with = "time::serde::rfc3339")]
pub uploaded_at: OffsetDateTime,
}
#[derive(Debug, Default)]
struct Inner {
files: HashMap<String, UnattendedMeta>,
}
/// In-memory + on-disk answer-file registry. Cheap to clone.
#[derive(Debug, Clone)]
pub struct UnattendedStore {
dir: Arc<PathBuf>,
inner: Arc<RwLock<Inner>>,
}
impl UnattendedStore {
#[must_use]
pub fn new(dir: PathBuf) -> Self {
Self {
dir: Arc::new(dir),
inner: Arc::new(RwLock::new(Inner::default())),
}
}
pub async fn ensure_dir(&self) -> Result<()> {
tokio::fs::create_dir_all(self.dir.as_path()).await?;
Ok(())
}
/// Scan the directory on startup, loading every `*.meta.json` sidecar.
pub async fn load_from_disk(&self) -> Result<()> {
self.ensure_dir().await?;
let mut entries = tokio::fs::read_dir(self.dir.as_path()).await?;
while let Some(e) = entries.next_entry().await? {
let p = e.path();
let is_meta = p
.file_name()
.and_then(|s| s.to_str())
.is_some_and(|n| n.ends_with(".meta.json"));
if !is_meta {
continue;
}
if let Ok(text) = tokio::fs::read_to_string(&p).await {
if let Ok(meta) = serde_json::from_str::<UnattendedMeta>(&text) {
self.inner.write().files.insert(meta.id.clone(), meta);
}
}
}
Ok(())
}
fn data_path(&self, id: &str) -> PathBuf {
self.dir.join(format!("{id}.file"))
}
fn meta_path(&self, id: &str) -> PathBuf {
self.dir.join(format!("{id}.meta.json"))
}
/// Mint a unique slug from the upload filename's stem.
fn unique_id(&self, filename: &str) -> String {
let stem = filename.rsplit_once('.').map_or(filename, |(s, _)| s);
let base = {
let s = slugify_str(stem);
if s.is_empty() {
"unattended".to_string()
} else {
s
}
};
let g = self.inner.read();
if !g.files.contains_key(&base) {
return base;
}
for n in 1.. {
let candidate = format!("{base}-{n}");
if !g.files.contains_key(&candidate) {
return candidate;
}
}
unreachable!("u64 ids exhausted")
}
/// Store an uploaded answer file. Validates type + size, classifies,
/// writes the bytes + a sidecar, and returns the new metadata.
pub async fn add(&self, filename: &str, bytes: &[u8]) -> Result<UnattendedMeta> {
if !is_accepted_filename(filename) {
return Err(Error::Invalid(format!(
"unsupported answer-file type '{filename}'. Accepted: .ks, .cfg, .seed, .yaml, .yml, .xml, user-data"
)));
}
if bytes.len() > MAX_UNATTENDED_BYTES {
return Err(Error::Invalid(format!(
"answer file too large ({} bytes, max {MAX_UNATTENDED_BYTES})",
bytes.len()
)));
}
self.ensure_dir().await?;
let kind = classify(filename, bytes);
let id = self.unique_id(filename);
let meta = UnattendedMeta {
id: id.clone(),
filename: filename.to_string(),
kind,
size_bytes: bytes.len() as u64,
uploaded_at: OffsetDateTime::now_utc(),
};
// Atomic data write: tmp -> rename.
let data = self.data_path(&id);
let tmp = data.with_extension("file.tmp");
tokio::fs::write(&tmp, bytes).await?;
tokio::fs::rename(&tmp, &data).await?;
let meta_text = serde_json::to_string_pretty(&meta).map_err(|e| Error::Other(e.into()))?;
tokio::fs::write(self.meta_path(&id), meta_text).await?;
self.inner.write().files.insert(id.clone(), meta.clone());
tracing::info!(
target: "openpxe::unattended",
id = %id, file = %filename, kind = ?kind, size = bytes.len(),
"unattended answer file stored"
);
Ok(meta)
}
#[must_use]
pub fn list(&self) -> Vec<UnattendedMeta> {
let g = self.inner.read();
let mut v: Vec<_> = g.files.values().cloned().collect();
v.sort_by_key(|m| std::cmp::Reverse(m.uploaded_at));
v
}
#[must_use]
pub fn get(&self, id: &str) -> Option<UnattendedMeta> {
self.inner.read().files.get(id).cloned()
}
/// Read the raw stored bytes for `id`.
pub async fn read(&self, id: &str) -> Result<Vec<u8>> {
if !self.inner.read().files.contains_key(id) {
return Err(Error::NotFound(format!("no unattended file '{id}'")));
}
let bytes = tokio::fs::read(self.data_path(id)).await?;
Ok(bytes)
}
/// Remove a file + its sidecar. Returns true if something was removed.
pub async fn remove(&self, id: &str) -> bool {
let existed = self.inner.write().files.remove(id).is_some();
if existed {
let _ = tokio::fs::remove_file(self.data_path(id)).await;
let _ = tokio::fs::remove_file(self.meta_path(id)).await;
}
existed
}
#[must_use]
pub fn len(&self) -> usize {
self.inner.read().files.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
/// Substitute the per-host template tokens into an answer file at serve
/// time. Recognised tokens (case-sensitive, double-brace): `{{HOSTNAME}}`,
/// `{{IP}}`, `{{MAC}}`. Unset values render as an empty string so a
/// half-filled profile never leaves a literal `{{IP}}` in the file.
#[must_use]
pub fn render_template(
content: &str,
mac: Option<&str>,
hostname: Option<&str>,
ip: Option<&str>,
) -> String {
content
.replace("{{HOSTNAME}}", hostname.unwrap_or(""))
.replace("{{IP}}", ip.unwrap_or(""))
.replace("{{MAC}}", mac.unwrap_or(""))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn classify_by_extension() {
assert_eq!(
classify(" subiquity.yaml", b""),
UnattendedKind::Autoinstall
);
assert_eq!(classify("ks.ks", b""), UnattendedKind::Kickstart);
assert_eq!(classify("preseed.seed", b""), UnattendedKind::Preseed);
assert_eq!(
classify("autounattend.xml", b"<xml/>"),
UnattendedKind::AnswerFile
);
assert_eq!(classify("user-data", b""), UnattendedKind::Autoinstall);
}
#[test]
fn classify_cfg_by_content() {
assert_eq!(
classify("answer.cfg", b"d-i debian-installer/locale string en_US"),
UnattendedKind::Preseed
);
assert_eq!(
classify("answer.cfg", b"install\n%packages\n@core\n%end\n"),
UnattendedKind::Kickstart
);
}
#[test]
fn accepted_filenames() {
assert!(is_accepted_filename("a.ks"));
assert!(is_accepted_filename("USER-DATA".to_lowercase().as_str()));
assert!(is_accepted_filename("autounattend.XML"));
assert!(!is_accepted_filename("evil.sh"));
assert!(!is_accepted_filename("image.iso"));
}
#[test]
fn template_substitutes_and_blanks_unset() {
let body = "ip={{IP}} host={{HOSTNAME}} mac={{MAC}}";
let out = render_template(body, Some("aa:bb"), Some("node1"), None);
assert_eq!(out, "ip= host=node1 mac=aa:bb");
}
#[tokio::test]
async fn add_list_read_remove_round_trip() {
let dir = tempdir().unwrap();
let s = UnattendedStore::new(dir.path().join("unattended"));
let meta = s
.add("rocky.ks", b"install\n%packages\n@core\n%end\n")
.await
.unwrap();
assert_eq!(meta.kind, UnattendedKind::Kickstart);
assert_eq!(s.len(), 1);
let got = s.read(&meta.id).await.unwrap();
assert!(got.starts_with(b"install"));
// Survives a reload.
let s2 = UnattendedStore::new(dir.path().join("unattended"));
s2.load_from_disk().await.unwrap();
assert!(s2.get(&meta.id).is_some());
assert!(s2.remove(&meta.id).await);
assert!(s2.get(&meta.id).is_none());
}
#[tokio::test]
async fn rejects_bad_type_and_oversize() {
let dir = tempdir().unwrap();
let s = UnattendedStore::new(dir.path().join("unattended"));
assert!(s.add("evil.sh", b"#!/bin/sh").await.is_err());
let big = vec![b'x'; MAX_UNATTENDED_BYTES + 1];
assert!(s.add("big.ks", &big).await.is_err());
}
#[tokio::test]
async fn ids_are_unique() {
let dir = tempdir().unwrap();
let s = UnattendedStore::new(dir.path().join("unattended"));
let a = s.add("ks.ks", b"install").await.unwrap();
let b = s.add("ks.ks", b"install").await.unwrap();
assert_ne!(a.id, b.id);
}
}
+13 -50
View File
@@ -54,10 +54,7 @@ pub struct WimPatcher {
impl WimPatcher { impl WimPatcher {
#[must_use] #[must_use]
pub fn new(smb_host: String, smb_share: String) -> Self { pub fn new(smb_host: String, smb_share: String) -> Self {
Self { Self { smb_host, smb_share }
smb_host,
smb_share,
}
} }
/// Apply WinPE patches to `boot.wim` inside `extracted_iso_dir`. Returns /// Apply WinPE patches to `boot.wim` inside `extracted_iso_dir`. Returns
@@ -75,40 +72,31 @@ impl WimPatcher {
let work = match tempfile::tempdir() { let work = match tempfile::tempdir() {
Ok(d) => d, Ok(d) => d,
Err(e) => { Err(e) => return WinPatchState::Failed { reason: format!("tempdir: {e}") },
return WinPatchState::Failed {
reason: format!("tempdir: {e}"),
}
}
}; };
// Stage the two files we want present at /Windows/System32/. // Stage the two files we want present at /Windows/System32/.
let staging = work.path().join("stage/Windows/System32"); let staging = work.path().join("stage/Windows/System32");
if let Err(e) = std::fs::create_dir_all(&staging) { if let Err(e) = std::fs::create_dir_all(&staging) {
return WinPatchState::Failed { return WinPatchState::Failed { reason: format!("staging mkdir: {e}") };
reason: format!("staging mkdir: {e}"),
};
} }
if let Err(e) = std::fs::write(staging.join("winpeshl.ini"), WINPESHL_INI) { if let Err(e) = std::fs::write(staging.join("winpeshl.ini"), WINPESHL_INI) {
return WinPatchState::Failed { return WinPatchState::Failed { reason: format!("write winpeshl.ini: {e}") };
reason: format!("write winpeshl.ini: {e}"),
};
} }
let startnet = render_startnet(&self.smb_host, &self.smb_share); let startnet = render_startnet(&self.smb_host, &self.smb_share);
if let Err(e) = std::fs::write(staging.join("startnet.cmd"), startnet) { if let Err(e) = std::fs::write(staging.join("startnet.cmd"), startnet) {
return WinPatchState::Failed { return WinPatchState::Failed { reason: format!("write startnet.cmd: {e}") };
reason: format!("write startnet.cmd: {e}"),
};
} }
// Build a wimlib update command file: // Build a wimlib update command file:
// add <stage>/Windows/System32 /Windows/System32 // add <stage>/Windows/System32 /Windows/System32
let update_file = work.path().join("update.cmd"); let update_file = work.path().join("update.cmd");
let update_cmd = format!("add \"{}\" \"/Windows/System32\"\n", staging.display()); let update_cmd = format!(
"add \"{}\" \"/Windows/System32\"\n",
staging.display()
);
if let Err(e) = std::fs::write(&update_file, update_cmd) { if let Err(e) = std::fs::write(&update_file, update_cmd) {
return WinPatchState::Failed { return WinPatchState::Failed { reason: format!("write update.cmd: {e}") };
reason: format!("write update.cmd: {e}"),
};
} }
// Run wimlib-imagex update against image index 2 (WinPE). // Run wimlib-imagex update against image index 2 (WinPE).
@@ -132,9 +120,7 @@ impl WimPatcher {
String::from_utf8_lossy(&o.stderr) String::from_utf8_lossy(&o.stderr)
), ),
}, },
Err(e) => WinPatchState::Failed { Err(e) => WinPatchState::Failed { reason: format!("spawn wimlib-imagex: {e}") },
reason: format!("spawn wimlib-imagex: {e}"),
},
} }
} }
} }
@@ -147,9 +133,7 @@ fn which(cmd: &str) -> Option<PathBuf> {
let paths = std::env::var_os("PATH")?; let paths = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&paths) { for dir in std::env::split_paths(&paths) {
let p = dir.join(cmd); let p = dir.join(cmd);
if p.is_file() { if p.is_file() { return Some(p); }
return Some(p);
}
} }
None None
} }
@@ -190,11 +174,7 @@ fn render_startnet(host: &str, share: &str) -> String {
) )
.unwrap(); .unwrap();
s.push_str(":havenet\r\n"); s.push_str(":havenet\r\n");
writeln!( writeln!(s, "echo Mapping install media from \\\\{host}\\{share}...\r").unwrap();
s,
"echo Mapping install media from \\\\{host}\\{share}...\r"
)
.unwrap();
writeln!( writeln!(
s, s,
":mapshare\r\nnet use Z: \\\\{host}\\{share} /user:guest \"\" /persistent:no && goto mapped\r\n\ ":mapshare\r\nnet use Z: \\\\{host}\\{share} /user:guest \"\" /persistent:no && goto mapped\r\n\
@@ -225,23 +205,6 @@ mod tests {
assert!(s.contains("setup.exe")); assert!(s.contains("setup.exe"));
} }
#[test]
fn startnet_primes_workstation_and_surfaces_mapping_errors() {
let s = render_startnet("10.0.0.5", "win11");
assert!(
s.contains("net start Workstation"),
"WinPE should explicitly start the SMB client before net use:\n{s}"
);
let net_use_line = s
.lines()
.find(|line| line.contains("net use Z:"))
.expect("net use line");
assert!(
!net_use_line.contains(">nul"),
"net use errors must remain visible in WinPE console: {net_use_line}"
);
}
#[test] #[test]
fn patcher_reports_wimlib_missing_gracefully() { fn patcher_reports_wimlib_missing_gracefully() {
// We don't assume wimlib is present in CI; this checks the missing // We don't assume wimlib is present in CI; this checks the missing
+34 -242
View File
@@ -4,16 +4,16 @@
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use openpxe_core::{ use openpxe_core::{
ClientRegistry, Config, DeploymentQueue, DhcpMode, HostBindings, LogBus, LogBusLayer, Metrics, ClientRegistry, Config, DhcpMode, DeploymentQueue, HostBindings, LogBus, LogBusLayer, Metrics,
SettingsStore, SettingsStore,
}; };
use openpxe_dhcp_proxy::DhcpProxyServer; use openpxe_dhcp_proxy::DhcpProxyServer;
use openpxe_http_api::{build_router, AppState}; use openpxe_http_api::{build_router, AppState};
use openpxe_iso_store::{IsoStore, NfsShareManager, SftpShareManager, SmbManager, SmbShareManager}; use openpxe_iso_store::{IsoStore, NfsManager, SmbManager};
use std::sync::Arc;
use openpxe_tftp::TftpServer; use openpxe_tftp::TftpServer;
use std::net::{Ipv4Addr, SocketAddr}; use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
@@ -39,7 +39,7 @@ enum Command {
/// docker run --rm \ /// docker run --rm \
/// -v /my/isos:/seed:ro \ /// -v /my/isos:/seed:ro \
/// -v openpxe-data:/var/lib/openpxe/isos \ /// -v openpxe-data:/var/lib/openpxe/isos \
/// openpxe:0.4.1 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)]
@@ -59,10 +59,11 @@ async fn main() -> anyhow::Result<()> {
init_tracing(log_bus.clone()); init_tracing(log_bus.clone());
let cli = Cli::parse(); let cli = Cli::parse();
// v0.5.4: layered load via figment — defaults → optional TOML → env. let mut config = match &cli.config {
// The OPENPXE_* env layer keeps the historical flat names (see Some(p) if p.exists() => Config::from_toml_file(p)?,
// `Config::load`), so existing deployments are unaffected. _ => Config::default(),
let config = Config::load(cli.config.as_deref())?; };
config.apply_env();
// Dispatch subcommands before bringing up the server. // Dispatch subcommands before bringing up the server.
if let Some(cmd) = cli.command { if let Some(cmd) = cli.command {
@@ -94,35 +95,14 @@ async fn main() -> anyhow::Result<()> {
} }
}, },
}; };
// v0.5.6: the advertised base URL must carry the HTTP port. Every let public_base_url = format!("http://{our_ip}");
// client-facing URL (the DHCP-proxy iPXE filename, UEFI HTTP boot,
// and the menu's kernel/initrd/ISO links) is derived from this one
// string, so omitting the port silently pointed PXE clients at :80 —
// breaking every non-80 deployment (e.g. the Unraid template's 4200,
// chosen to dodge the webGUI). See `build_public_base_url`.
let public_base_url = build_public_base_url(our_ip, config.server.http_port);
let iso_store = IsoStore::new(config.paths.iso_dir.clone()); let iso_store = IsoStore::new(config.paths.iso_dir.clone());
iso_store.load_from_disk().await?; iso_store.load_from_disk().await?;
// v0.5.2: unattended answer-file store (Kickstart/Preseed/Autoinstall/
// Windows answer files). Separate directory from the ISO store.
let unattended = openpxe_iso_store::UnattendedStore::new(config.paths.unattended_dir.clone());
if let Err(e) = unattended.load_from_disk().await {
tracing::warn!(
target: "openpxe::unattended",
"could not load unattended files on startup: {e}"
);
}
let clients = ClientRegistry::new(); let clients = ClientRegistry::new();
let queue = DeploymentQueue::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 hosts = HostBindings::load_or_default(&config.paths.work_dir);
let boot_log = openpxe_core::BootLog::load_or_default(&config.paths.work_dir);
let branding = openpxe_core::BrandingStore::load_or_default(&config.paths.work_dir);
let admin = openpxe_core::AdminStore::load_or_default(&config.paths.work_dir);
let sso = openpxe_core::SsoStore::load_or_default(&config.paths.work_dir);
let notify = openpxe_core::NotifyStore::load_or_default(&config.paths.work_dir);
let sessions = openpxe_http_api::auth::SessionStore::default();
let metrics = Metrics::new(); let metrics = Metrics::new();
// Build the SMB manager unconditionally — it starts/stops on the // Build the SMB manager unconditionally — it starts/stops on the
@@ -134,43 +114,13 @@ async fn main() -> anyhow::Result<()> {
let _ = smb.start(); let _ = smb.start();
} }
// v0.4.65: SMB share manager — Samba `smbclient` userspace // NFS manager. The mount root has to be set on the IsoStore *before*
// consumer. Replaces the kernel-mount NFS path that v0.4.64 // we replay any persisted mounts, otherwise an in-memory IsoMeta
// shipped; that didn't work on hosts whose kernel lacked the nfs // pointing at an NFS source can't resolve to a path.
// client modules (Unraid is the dominant case). `smbclient` does let nfs = NfsManager::new(&config.paths.work_dir, iso_store.clone());
// the SMB protocol entirely in userspace over TCP and works in iso_store.set_nfs_root(nfs.mount_root());
// any container regardless of capabilities or kernel modules. if let Err(e) = nfs.load_and_remount().await {
let smb_shares = SmbShareManager::new(&config.paths.work_dir, iso_store.clone()); tracing::warn!(target: "openpxe::nfs", "could not reload NFS mounts: {e}");
if let Err(e) = smb_shares.load_and_rescan().await {
tracing::warn!(
target: "openpxe::smb",
"could not reload SMB shares on startup: {e}"
);
}
// v0.4.67: NFSv3 share manager — pure-Rust in-process consumer
// via the `nfs3_client` crate. Sits alongside the SMB manager;
// operators pick whichever protocol their NAS prefers, or use
// both. No subprocess, no kernel mount, works in any container.
let nfs_shares = NfsShareManager::new(&config.paths.work_dir, iso_store.clone());
if let Err(e) = nfs_shares.load_and_rescan().await {
tracing::warn!(
target: "openpxe::nfs",
"could not reload NFS shares on startup: {e}"
);
}
// v0.5.5: SFTP-over-SSH share manager — pure-Rust in-process
// consumer via `russh` + `russh-sftp` (ring backend, no OpenSSL).
// The third remote-library protocol alongside SMB/NFS; like NFS it
// works in any container (no subprocess, no kernel mount) and
// supports HTTP Range requests because SFTP file handles seek.
let sftp_shares = SftpShareManager::new(&config.paths.work_dir, iso_store.clone());
if let Err(e) = sftp_shares.load_and_rescan().await {
tracing::warn!(
target: "openpxe::sftp",
"could not reload SFTP shares on startup: {e}"
);
} }
// Sniff network details for the Network tab. None of these are // Sniff network details for the Network tab. None of these are
@@ -184,38 +134,19 @@ async fn main() -> anyhow::Result<()> {
"network info" "network info"
); );
// v0.7.1: boot rules are shared between the HTTP layer (target rules,
// webhook, the editor API) and the DHCP proxy (driver-mode pins).
let boot_rules = openpxe_core::BootRulesStore::load_or_default(&config.paths.work_dir);
let state = AppState { let state = AppState {
iso_store: iso_store.clone(), iso_store: iso_store.clone(),
clients: clients.clone(), clients: clients.clone(),
settings: settings.clone(), settings: settings.clone(),
queue: queue.clone(), queue: gates.clone(),
hosts: hosts.clone(), hosts: hosts.clone(),
boot_log: boot_log.clone(),
boot_rules: boot_rules.clone(),
boot_tokens: openpxe_core::BootTokens::new(),
branding: branding.clone(),
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
admin: admin.clone(),
sessions: sessions.clone(),
sso: sso.clone(),
saml: openpxe_http_api::saml_routes::SamlRuntime::default(),
notify: notify.clone(),
metrics: metrics.clone(), metrics: metrics.clone(),
smb: Some(smb.clone()), smb: Some(smb.clone()),
smb_shares: smb_shares.clone(), nfs: nfs.clone(),
nfs_shares: nfs_shares.clone(),
sftp_shares: sftp_shares.clone(),
unattended: unattended.clone(),
uploads: openpxe_http_api::uploads::UploadSessions::default(),
log_bus: log_bus.clone(), log_bus: log_bus.clone(),
started_at: time::OffsetDateTime::now_utc(), started_at: time::OffsetDateTime::now_utc(),
public_base_url: public_base_url.clone(), public_base_url: public_base_url.clone(),
nic_name: net.nic_name, nic_name: net.nic_name,
nic_link: net.nic_link,
subnet_mask: net.subnet_mask, subnet_mask: net.subnet_mask,
gateway: net.gateway, gateway: net.gateway,
}; };
@@ -225,40 +156,15 @@ async fn main() -> anyhow::Result<()> {
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: "openpxe::http", "HTTP listening on {http_addr}"); tracing::info!(target: "openpxe::http", "HTTP listening on {http_addr}");
// `into_make_service_with_connect_info` is required so per-request axum::serve(listener, router).await?;
// `ConnectInfo<SocketAddr>` extractors can resolve the peer IP —
// used by `/boot/<entry>.ipxe` to record the booting client's
// address into the Host log. Without this the extractor 500s.
axum::serve(
listener,
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await?;
Ok::<_, anyhow::Error>(()) Ok::<_, anyhow::Error>(())
}); });
// v0.7.0: TFTP names that aren't embedded assets get a dynamic
// renderer — `grub.cfg` for the Secure Boot shim+GRUB chain is
// generated from the live boot-entry list on every fetch, so menu
// changes apply without restart.
let grub_isos = iso_store.clone();
let grub_base = public_base_url.clone();
let tftp_dynamic: openpxe_tftp::DynamicAsset = std::sync::Arc::new(move |name: &str| {
if name == "grub.cfg" || name.starts_with("grub.cfg-") {
Some(
openpxe_http_api::grub_script::render_grub_menu(&grub_isos.list(), &grub_base)
.into_bytes(),
)
} else {
None
}
});
let tftp = TftpServer::new( let tftp = TftpServer::new(
config.server.tftp_bind, config.server.tftp_bind,
config.server.tftp_port, config.server.tftp_port,
clients.clone(), clients.clone(),
metrics.clone(), metrics.clone(),
Some(tftp_dynamic),
); );
let tftp_task = tokio::spawn(tftp.run()); let tftp_task = tokio::spawn(tftp.run());
@@ -272,10 +178,6 @@ async fn main() -> anyhow::Result<()> {
public_base_url.clone(), public_base_url.clone(),
clients.clone(), clients.clone(),
metrics.clone(), metrics.clone(),
// v0.7.1: learned driver modes persist next to the other
// state files, so a machine walks the ladder once *ever*.
openpxe_dhcp_proxy::DriverEscalation::load_or_default(&config.paths.work_dir),
boot_rules.clone(),
); );
tokio::spawn(s.run()) tokio::spawn(s.run())
} }
@@ -308,11 +210,7 @@ async fn run_command(cmd: Command, config: Config) -> anyhow::Result<()> {
/// Reuses `IsoStore::begin_upload` / `finish` so the resulting meta on disk /// Reuses `IsoStore::begin_upload` / `finish` so the resulting meta on disk
/// is identical to a web upload — same slug rules, same introspection, same /// is identical to a web upload — same slug rules, same introspection, same
/// sha256. /// sha256.
async fn seed_from_dir( async fn seed_from_dir(src: &std::path::Path, config: &Config, dry_run: bool) -> anyhow::Result<()> {
src: &std::path::Path,
config: &Config,
dry_run: bool,
) -> anyhow::Result<()> {
let store = IsoStore::new(config.paths.iso_dir.clone()); let store = IsoStore::new(config.paths.iso_dir.clone());
store.load_from_disk().await?; store.load_from_disk().await?;
let mut entries = tokio::fs::read_dir(src).await?; let mut entries = tokio::fs::read_dir(src).await?;
@@ -320,12 +218,7 @@ async fn seed_from_dir(
let mut skipped = 0u32; let mut skipped = 0u32;
while let Some(entry) = entries.next_entry().await? { while let Some(entry) = entries.next_entry().await? {
let p = entry.path(); let p = entry.path();
if p.extension() if p.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() != Some("iso") {
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
!= Some("iso")
{
continue; continue;
} }
let filename = p let filename = p
@@ -333,14 +226,8 @@ async fn seed_from_dir(
.and_then(|s| s.to_str()) .and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("non-utf8 filename: {}", p.display()))? .ok_or_else(|| anyhow::anyhow!("non-utf8 filename: {}", p.display()))?
.to_string(); .to_string();
println!( println!(" {} ({} bytes)", filename, tokio::fs::metadata(&p).await?.len());
" {} ({} bytes)", if dry_run { continue; }
filename,
tokio::fs::metadata(&p).await?.len()
);
if dry_run {
continue;
}
let mut handle = match store.begin_upload(&filename).await { let mut handle = match store.begin_upload(&filename).await {
Ok(h) => h, Ok(h) => h,
@@ -355,23 +242,15 @@ async fn seed_from_dir(
let mut buf = vec![0u8; 1024 * 1024]; let mut buf = vec![0u8; 1024 * 1024];
loop { loop {
let n = file.read(&mut buf).await?; let n = file.read(&mut buf).await?;
if n == 0 { if n == 0 { break; }
break;
}
let chunk: bytes::Bytes = buf[..n].to_vec().into(); let chunk: bytes::Bytes = buf[..n].to_vec().into();
handle.write_chunk(&chunk).await?; handle.write_chunk(&chunk).await?;
} }
let meta = handle.finish(&store).await?; let meta = handle.finish(&store).await?;
println!( println!(" -> id={} family={:?}", meta.id, meta.introspection.family);
" -> id={} family={:?}",
meta.id, meta.introspection.family
);
imported += 1; imported += 1;
} }
println!( println!("\nimported={imported} skipped={skipped} {}", if dry_run { "(dry run)" } else { "" });
"\nimported={imported} skipped={skipped} {}",
if dry_run { "(dry run)" } else { "" }
);
Ok(()) Ok(())
} }
@@ -380,20 +259,6 @@ async fn seed_from_dir(
/// 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
/// `OPENPXE_PUBLIC_IP` explicitly. /// `OPENPXE_PUBLIC_IP` explicitly.
/// Build the base URL advertised to PXE clients. The port is included
/// unless it's the HTTP default (80), keeping the common case clean
/// (`http://10.0.0.5`) while a remapped port (`http://10.0.0.5:4200`)
/// stays reachable. This is the single source of truth for every
/// client-facing URL — the DHCP-proxy iPXE filename, UEFI HTTP boot, and
/// the boot menu's kernel/initrd/ISO links all derive from it.
fn build_public_base_url(ip: Ipv4Addr, http_port: u16) -> String {
if http_port == 80 {
format!("http://{ip}")
} else {
format!("http://{ip}:{http_port}")
}
}
fn detect_primary_ipv4() -> Option<Ipv4Addr> { 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`
@@ -429,8 +294,9 @@ fn hostname() -> std::io::Result<String> {
if let Ok(h) = std::fs::read_to_string("/proc/sys/kernel/hostname") { if let Ok(h) = std::fs::read_to_string("/proc/sys/kernel/hostname") {
return Ok(h.trim().to_string()); return Ok(h.trim().to_string());
} }
std::env::var("HOSTNAME") std::env::var("HOSTNAME").map_err(|_| std::io::Error::new(
.map_err(|_| std::io::Error::new(std::io::ErrorKind::NotFound, "no hostname")) std::io::ErrorKind::NotFound, "no hostname",
))
} }
fn init_tracing(bus: Arc<LogBus>) { fn init_tracing(bus: Arc<LogBus>) {
@@ -449,12 +315,6 @@ struct NetworkInfo {
nic_name: String, nic_name: String,
subnet_mask: String, subnet_mask: String,
gateway: String, gateway: String,
/// v0.7.2: physical link summary for the Network tab — operstate,
/// negotiated speed/duplex, and the port's own MAC. Helps operators
/// in multi-NIC / trunked environments confirm *which* port the PXE
/// server actually answers on. Empty when sysfs isn't available
/// (non-Linux dev builds) or the NIC wasn't identified.
nic_link: String,
} }
/// Best-effort population of the Network tab's read-only fields. We shell /// Best-effort population of the Network tab's read-only fields. We shell
@@ -468,10 +328,7 @@ fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo {
// `ip -o -f inet addr show` lists every interface with its // `ip -o -f inet addr show` lists every interface with its
// `inet a.b.c.d/mask`. We match the line that mentions our IP. // `inet a.b.c.d/mask`. We match the line that mentions our IP.
if let Ok(out) = Command::new("ip") if let Ok(out) = Command::new("ip").args(["-o", "-f", "inet", "addr", "show"]).output() {
.args(["-o", "-f", "inet", "addr", "show"])
.output()
{
if let Ok(text) = String::from_utf8(out.stdout) { if let Ok(text) = String::from_utf8(out.stdout) {
for line in text.lines() { for line in text.lines() {
if !line.contains(&our_ip.to_string()) { if !line.contains(&our_ip.to_string()) {
@@ -496,10 +353,7 @@ fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo {
} }
// `ip route show default` -> "default via 10.0.0.1 dev enp1s0 ..." // `ip route show default` -> "default via 10.0.0.1 dev enp1s0 ..."
if let Ok(out) = Command::new("ip") if let Ok(out) = Command::new("ip").args(["route", "show", "default"]).output() {
.args(["route", "show", "default"])
.output()
{
if let Ok(text) = String::from_utf8(out.stdout) { if let Ok(text) = String::from_utf8(out.stdout) {
if let Some(line) = text.lines().next() { if let Some(line) = text.lines().next() {
let mut parts = line.split_whitespace(); let mut parts = line.split_whitespace();
@@ -515,51 +369,12 @@ fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo {
} }
} }
info.nic_link = detect_link_info(&info.nic_name);
info info
} }
/// v0.7.2: read the NIC's physical link details from sysfs. Every field
/// is optional — virtual NICs report no speed (`-1` or absent), and
/// non-Linux dev machines have no `/sys/class/net` at all — so the
/// result is whatever could be read, joined human-readably, or empty.
fn detect_link_info(nic: &str) -> String {
if nic.is_empty() {
return String::new();
}
let read = |file: &str| {
std::fs::read_to_string(format!("/sys/class/net/{nic}/{file}"))
.map(|s| s.trim().to_string())
.unwrap_or_default()
};
let mut parts: Vec<String> = Vec::new();
let state = read("operstate");
if !state.is_empty() {
parts.push(format!("link {state}"));
}
let speed = read("speed");
if !speed.is_empty() && speed != "-1" {
parts.push(format!("{speed} Mb/s"));
}
let duplex = read("duplex");
if !duplex.is_empty() && duplex != "unknown" {
parts.push(format!("{duplex} duplex"));
}
let mac = read("address");
if !mac.is_empty() {
parts.push(format!("port {mac}"));
}
parts.join(" · ")
}
fn prefix_to_dotted(prefix: u8) -> String { fn prefix_to_dotted(prefix: u8) -> String {
let prefix = prefix.min(32); let prefix = prefix.min(32);
let mask: u32 = if prefix == 0 { let mask: u32 = if prefix == 0 { 0 } else { u32::MAX << (32 - prefix) };
0
} else {
u32::MAX << (32 - prefix)
};
format!( format!(
"{}.{}.{}.{}", "{}.{}.{}.{}",
(mask >> 24) & 0xff, (mask >> 24) & 0xff,
@@ -568,26 +383,3 @@ fn prefix_to_dotted(prefix: u8) -> String {
mask & 0xff mask & 0xff
) )
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn public_base_url_includes_non_default_port() {
// The v0.5.6 regression guard: a remapped HTTP port (e.g. the
// Unraid template's 4200) MUST appear in the advertised URL, or
// PXE clients fetch :80 — the wrong service — and boot fails.
let ip: Ipv4Addr = "192.168.1.49".parse().unwrap();
assert_eq!(build_public_base_url(ip, 4200), "http://192.168.1.49:4200");
assert_eq!(build_public_base_url(ip, 8080), "http://192.168.1.49:8080");
}
#[test]
fn public_base_url_omits_default_port() {
// Port 80 stays clean (no `:80`) so the common case reads nicely
// and matches what every browser/iPXE assumes by default.
let ip: Ipv4Addr = "10.0.0.5".parse().unwrap();
assert_eq!(build_public_base_url(ip, 80), "http://10.0.0.5");
}
}
+1 -1
View File
@@ -14,4 +14,4 @@
pub mod server; pub mod server;
pub use server::{DynamicAsset, TftpServer}; pub use server::TftpServer;
+31 -101
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 `openpxe_ipxe_assets::asset_slice` — 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 openpxe_core::{ClientEvent, ClientRegistry}; use openpxe_core::{ClientEvent, ClientRegistry};
use openpxe_ipxe_assets::asset_slice; 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;
@@ -21,7 +21,6 @@ use tokio::net::UdpSocket;
// TFTP opcodes. // TFTP opcodes.
const OP_RRQ: u16 = 1; const OP_RRQ: u16 = 1;
const OP_WRQ: u16 = 2;
const OP_DATA: u16 = 3; const OP_DATA: u16 = 3;
const OP_ACK: u16 = 4; const OP_ACK: u16 = 4;
const OP_ERROR: u16 = 5; const OP_ERROR: u16 = 5;
@@ -32,19 +31,11 @@ const ERR_NOT_DEFINED: u16 = 0;
const ERR_FILE_NOT_FOUND: u16 = 1; const ERR_FILE_NOT_FOUND: u16 = 1;
const ERR_ILLEGAL_OP: u16 = 4; const ERR_ILLEGAL_OP: u16 = 4;
/// Server-rendered TFTP content for names that aren't embedded assets —
/// e.g. `grub.cfg` for the signed shim+GRUB Secure Boot chain (v0.7.0),
/// which is generated from the live boot-entry list per fetch. Kept as a
/// closure so this crate stays decoupled from the ISO store; the binary
/// wires it up in `main`.
pub type DynamicAsset = Arc<dyn Fn(&str) -> Option<Vec<u8>> + Send + Sync>;
pub struct TftpServer { pub struct TftpServer {
bind: IpAddr, bind: IpAddr,
port: u16, port: u16,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics, metrics: openpxe_core::Metrics,
dynamic: Option<DynamicAsset>,
} }
impl TftpServer { impl TftpServer {
@@ -53,15 +44,8 @@ impl TftpServer {
port: u16, port: u16,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics, metrics: openpxe_core::Metrics,
dynamic: Option<DynamicAsset>,
) -> Self { ) -> Self {
Self { Self { bind, port, clients, metrics }
bind,
port,
clients,
metrics,
dynamic,
}
} }
pub async fn run(self) -> anyhow::Result<()> { pub async fn run(self) -> anyhow::Result<()> {
@@ -82,11 +66,8 @@ impl TftpServer {
let clients = clients.clone(); let clients = clients.clone();
let metrics = metrics.clone(); let metrics = metrics.clone();
let bind_ip = self.bind; let bind_ip = self.bind;
let dynamic = self.dynamic.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = if let Err(e) = handle_rrq(data, from, bind_ip, clients, metrics.clone()).await {
handle_rrq(data, from, bind_ip, clients, metrics.clone(), dynamic).await
{
metrics.record_tftp_err(); metrics.record_tftp_err();
tracing::warn!(target: "openpxe::tftp", peer=%from, "handler error: {e}"); tracing::warn!(target: "openpxe::tftp", peer=%from, "handler error: {e}");
} }
@@ -101,54 +82,23 @@ async fn handle_rrq(
bind_ip: IpAddr, bind_ip: IpAddr,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics, metrics: openpxe_core::Metrics,
dynamic: Option<DynamicAsset>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let Some(req) = parse_rrq(&packet) else { let Some(req) = parse_rrq(&packet) else {
// Not a well-formed RRQ. A WRQ deserves an explicit refusal —
// legacy clients retry a silently-dropped write until they time
// out; an ERROR packet fails them fast with a readable reason.
if packet.len() >= 2 && u16::from_be_bytes([packet[0], packet[1]]) == OP_WRQ {
let sock = bind_udp(bind_ip, 0)?;
let _ = send_error(&sock, peer, ERR_ILLEGAL_OP, "writes not supported").await;
}
return Ok(()); return Ok(());
}; };
let Request { let Request { filename, options, .. } = req;
filename,
mode,
options,
} = req;
// Per-transfer ephemeral socket. // Per-transfer ephemeral socket.
let sock = bind_udp(bind_ip, 0)?; let sock = bind_udp(bind_ip, 0)?;
// We serve binary boot artifacts; netascii line-ending translation let Some(file_bytes) = asset_bytes(&filename) else {
// would corrupt them. Refuse loudly instead of timing out silently —
// matters for legacy clients that default to netascii.
if !mode.eq_ignore_ascii_case("octet") {
let _ = send_error(&sock, peer, ERR_NOT_DEFINED, "only octet mode is supported").await;
tracing::info!(target: "openpxe::tftp", peer=%peer, %mode, "rejected non-octet transfer");
return Ok(());
}
// Embedded assets first; otherwise the dynamic renderer (server-
// generated content like the Secure Boot chain's grub.cfg, v0.7.0).
let resolved = asset_slice(&filename).or_else(|| {
dynamic
.as_ref()
.and_then(|f| f(&filename))
.map(std::borrow::Cow::Owned)
});
let Some(file_bytes) = resolved 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: "openpxe::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()),
None, None,
ClientEvent::TftpRead { ClientEvent::TftpRead { file: filename.clone() },
file: filename.clone(),
},
); );
return Ok(()); return Ok(());
}; };
@@ -162,9 +112,7 @@ async fn handle_rrq(
&peer.ip().to_string(), &peer.ip().to_string(),
Some(peer.ip()), Some(peer.ip()),
None, None,
ClientEvent::TftpRead { ClientEvent::TftpRead { file: filename.clone() },
file: filename.clone(),
},
); );
// Negotiate options. // Negotiate options.
@@ -225,9 +173,7 @@ async fn handle_rrq(
// Send one window worth of DATA. // Send one window worth of DATA.
for _ in 0..window { for _ in 0..window {
if offset >= total { if offset >= total { break; }
break;
}
let end = (offset + blksize).min(total); let end = (offset + blksize).min(total);
let chunk = &file_bytes[offset..end]; let chunk = &file_bytes[offset..end];
let pkt = encode_data(block_no, chunk); let pkt = encode_data(block_no, chunk);
@@ -303,35 +249,26 @@ async fn handle_rrq(
#[derive(Debug)] #[derive(Debug)]
struct Request { struct Request {
filename: String, filename: String,
#[allow(dead_code)]
mode: String, mode: String,
options: Vec<(String, String)>, options: Vec<(String, String)>,
} }
fn parse_rrq(pkt: &[u8]) -> Option<Request> { fn parse_rrq(pkt: &[u8]) -> Option<Request> {
if pkt.len() < 4 { if pkt.len() < 4 { return None; }
return None;
}
let op = u16::from_be_bytes([pkt[0], pkt[1]]); let op = u16::from_be_bytes([pkt[0], pkt[1]]);
if op != OP_RRQ { if op != OP_RRQ { return None; }
return None;
}
let mut rest = &pkt[2..]; let mut rest = &pkt[2..];
let filename = read_cstr(&mut rest)?; let filename = read_cstr(&mut rest)?;
let mode = read_cstr(&mut rest)?; let mode = read_cstr(&mut rest)?;
let mut options = Vec::new(); let mut options = Vec::new();
while !rest.is_empty() { while !rest.is_empty() {
let Some(k) = read_cstr(&mut rest) else { break }; let Some(k) = read_cstr(&mut rest) else { break };
if k.is_empty() { if k.is_empty() { break; }
break;
}
let v = read_cstr(&mut rest).unwrap_or_default(); let v = read_cstr(&mut rest).unwrap_or_default();
options.push((k.to_ascii_lowercase(), v)); options.push((k.to_ascii_lowercase(), v));
} }
Some(Request { Some(Request { filename, mode, options })
filename,
mode,
options,
})
} }
fn read_cstr(buf: &mut &[u8]) -> Option<String> { fn read_cstr(buf: &mut &[u8]) -> Option<String> {
@@ -379,12 +316,8 @@ async fn recv_ack(sock: &UdpSocket, peer: SocketAddr) -> anyhow::Result<u16> {
let mut buf = [0u8; 32]; let mut buf = [0u8; 32];
loop { loop {
let (n, from) = sock.recv_from(&mut buf).await?; let (n, from) = sock.recv_from(&mut buf).await?;
if from.ip() != peer.ip() { if from.ip() != peer.ip() { continue; }
continue; if n < 4 { continue; }
}
if n < 4 {
continue;
}
let op = u16::from_be_bytes([buf[0], buf[1]]); let op = u16::from_be_bytes([buf[0], buf[1]]);
match op { match op {
OP_ACK => return Ok(u16::from_be_bytes([buf[2], buf[3]])), OP_ACK => return Ok(u16::from_be_bytes([buf[2], buf[3]])),
@@ -411,19 +344,14 @@ async fn wait_for_ack(
Ok(Ok(_)) => {} Ok(Ok(_)) => {}
Ok(Err(_)) | Err(_) => { Ok(Err(_)) | Err(_) => {
tries += 1; tries += 1;
if tries > 5 { if tries > 5 { return Ok(false); }
return Ok(false);
}
} }
} }
} }
} }
fn bind_udp(bind: IpAddr, port: u16) -> anyhow::Result<UdpSocket> { fn bind_udp(bind: IpAddr, port: u16) -> anyhow::Result<UdpSocket> {
let domain = match bind { let domain = match bind { IpAddr::V4(_) => Domain::IPV4, IpAddr::V6(_) => Domain::IPV6 };
IpAddr::V4(_) => Domain::IPV4,
IpAddr::V6(_) => Domain::IPV6,
};
let sock = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP))?; let sock = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP))?;
sock.set_reuse_address(true)?; sock.set_reuse_address(true)?;
sock.set_nonblocking(true)?; sock.set_nonblocking(true)?;
@@ -433,13 +361,17 @@ fn bind_udp(bind: IpAddr, port: u16) -> anyhow::Result<UdpSocket> {
Ok(UdpSocket::from_std(std_sock)?) Ok(UdpSocket::from_std(std_sock)?)
} }
/// Pure-logic mirror of `handle_rrq`'s windowing math, exercised by the #[allow(dead_code)]
/// unit tests below. Given a position in the file and the window, return const _UNUSED: (u16, u16) = (ERR_NOT_DEFINED, ERR_ILLEGAL_OP);
/// the (block_no, chunk_len) list this window will emit — tested against
/// edge cases (exact-blksize tail, short tail, single-block window, /// Pure-logic helper used by the unit tests below and (in a refactor) by
/// block-number wraparound). /// `handle_rrq`. Given a position in the file and the window, return the
#[cfg(test)] /// (block_no, chunk_len) list this window will emit. Useful as a sanity
fn plan_window( /// check that our windowing math matches the wire behavior the spec
/// requires — tested against edge cases (exact-blksize tail, short tail,
/// single-block window).
#[must_use]
pub fn plan_window(
total: usize, total: usize,
offset: usize, offset: usize,
blksize: usize, blksize: usize,
@@ -450,9 +382,7 @@ fn plan_window(
let mut o = offset; let mut o = offset;
let mut b = starting_block; let mut b = starting_block;
for _ in 0..window { for _ in 0..window {
if o >= total { if o >= total { break; }
break;
}
let end = (o + blksize).min(total); let end = (o + blksize).min(total);
out.push((b, end - o)); out.push((b, end - o));
o = end; o = end;
@@ -523,7 +453,7 @@ mod tests {
assert_eq!(p, vec![(65534, 1024), (65535, 1024)]); assert_eq!(p, vec![(65534, 1024), (65535, 1024)]);
let p2 = plan_window(2048, 2048, 1024, 2, 0); let p2 = plan_window(2048, 2048, 1024, 2, 0);
assert!(p2.is_empty()); // nothing past EOF assert!(p2.is_empty()); // nothing past EOF
// And a cross-boundary case: // And a cross-boundary case:
let p3 = plan_window(3072, 0, 1024, 3, 65535); let p3 = plan_window(3072, 0, 1024, 3, 65535);
assert_eq!(p3, vec![(65535, 1024), (0, 1024), (1, 1024)]); assert_eq!(p3, vec![(65535, 1024), (0, 1024), (1, 1024)]);
} }
+46 -508
View File
@@ -8,49 +8,37 @@
* CSS lands). */ * CSS lands). */
:root { :root {
/* Jet-black dark palette (default). Modelled on Netbox Labs's /* Dark palette (default). */
near-black product chrome, with surfaces stepping subtly upward --bg: #0b1018;
rather than the previous blue-tinted ramp, so the UI reads as a --bg-panel: #121826;
genuine "dark" rather than "dim navy". */ --bg-panel-2: #1a2334;
--bg: #030303; --bg-elev: #223047;
--bg-panel: #0a0a0a; --fg: #e4e8ef;
--bg-panel-2: #141414; --fg-dim: #8a94a7;
--bg-elev: #1c1c1c; --fg-dimmer: #5a6379;
--fg: #e8eaed; --accent: #00d4b4; /* Netbox-ish teal */
--fg-dim: #9aa0a6;
--fg-dimmer: #6b7077;
--accent: #00d4b4; /* Netbox-ish teal — kept for brand */
--accent-dim: #07a38c; --accent-dim: #07a38c;
--warn: #ffb347; --warn: #ffb347;
--err: #ef6e6e; --err: #ef6e6e;
--ok: #4ade80; --ok: #4ade80;
--border: #1f1f1f; --border: #223047;
--border-soft: #141414; --border-soft: #172033;
--terminal-bg: #050505; --terminal-bg: #06090e;
--shadow-card: 0 1px 0 rgba(255,255,255,0.02), 0 8px 24px rgba(0,0,0,0.55); --shadow-card: 0 1px 0 rgba(255,255,255,0.02), 0 8px 24px rgba(0,0,0,0.25);
--radius: 6px; --radius: 6px;
--radius-lg: 10px; --radius-lg: 10px;
--sidebar-w: 240px; --sidebar-w: 240px;
--topbar-h: 56px; --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;
/* v0.4.63: tie native form-control rendering (checkboxes, scroll bars,
date pickers) to the active OpenPXE theme. Without this, the inline
`<meta name="color-scheme" content="dark light">` in index.html forces
dark form chrome in *both* themes so the SSO "Enable single sign-on"
checkbox renders as an opaque black square against the light-mode
panel, ignoring our accent-color hint. CSS `color-scheme` overrides
the meta and tracks `data-theme` correctly. */
color-scheme: dark;
} }
:root[data-theme="light"] { :root[data-theme="light"] {
color-scheme: light;
/* Light palette high-contrast neutral, accent unchanged for brand /* Light palette high-contrast neutral, accent unchanged for brand
consistency. Designed against Netbox Labs's reference screenshot: consistency. Designed against Netbox Labs's reference screenshot:
near-white surfaces, soft grey dividers, dark text. */ near-white surfaces, soft grey dividers, dark text. */
--bg: #f6f8fb; --bg: #f6f8fb;
--bg-panel: #fbfcfe; --bg-panel: #ffffff;
--bg-panel-2: #f0f3f8; --bg-panel-2: #f0f3f8;
--bg-elev: #e6ebf2; --bg-elev: #e6ebf2;
--fg: #1c2330; --fg: #1c2330;
@@ -63,11 +51,7 @@
--ok: #1f9b54; --ok: #1f9b54;
--border: #d8dde6; --border: #d8dde6;
--border-soft: #e7eaf0; --border-soft: #e7eaf0;
/* Light-mode terminal: the pane background and chrome track the rest --terminal-bg: #0d1219; /* terminal stays dark even in light mode */
of the light theme. Per-level text colours below recolour-on-light
so log lines stay readable on a pale background previously the
terminal was locked to dark and looked like a stuck panel. */
--terminal-bg: #ffffff;
--shadow-card: 0 1px 0 rgba(0,0,0,0.02), 0 6px 18px rgba(20,28,52,0.06); --shadow-card: 0 1px 0 rgba(0,0,0,0.02), 0 6px 18px rgba(20,28,52,0.06);
} }
@@ -100,37 +84,14 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
display: flex; flex-direction: column; display: flex; flex-direction: column;
} }
/* The brand block sits flush with the topbar so the sidebar+topbar reads
as one continuous bar across the top of the app, rather than a chunky
2-line logo block plus a separate (smaller-typeface) page title. The
height/border-bottom match the topbar exactly so the divider runs
straight across without a step. */
.sidebar .brand { .sidebar .brand {
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 12px;
padding: 0 18px; padding: 14px 18px;
height: var(--topbar-h);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.sidebar .brand img { width: 26px; height: 26px; flex: none; } .sidebar .brand img { width: 40px; height: auto; }
.sidebar .brand strong { .sidebar .brand strong { font-size: 16px; letter-spacing: 0.4px; }
font-size: 15px; font-weight: 600; .sidebar .brand .sub { color: var(--fg-dim); font-size: 11px; }
letter-spacing: 0.2px;
color: var(--fg);
}
/* v0.4.69: FleetDM-style full-width custom logo. When the operator has
uploaded a custom brand mark, the sidebar header drops the bundled
26px mark + "OpenPXE" wordmark and instead lets the uploaded image
span the header left-aligned, capped at 200x50, scaled to fit
without distortion. The wordmark is hidden so the operator's logo is
the sole brand element (their logo presumably already contains their
name). The bundled-default case keeps the mark + wordmark. */
.sidebar .brand.has-custom-logo { gap: 0; }
.sidebar .brand.has-custom-logo img {
width: auto; height: 50px; max-width: 200px;
object-fit: contain; object-position: left center; flex: none;
}
.sidebar .brand.has-custom-logo strong { display: none; }
.sidebar nav { padding: 10px 0; flex: 1; overflow-y: auto; } .sidebar nav { padding: 10px 0; flex: 1; overflow-y: auto; }
.sidebar nav a { .sidebar nav a {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
@@ -152,40 +113,10 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
} }
.sidebar nav a.active .count { background: var(--accent); color: #002923; } .sidebar nav a.active .count { background: var(--accent); color: #002923; }
.sidebar .footer { .sidebar .footer {
padding: 12px 18px; border-top: 1px solid var(--border); padding: 10px 18px; border-top: 1px solid var(--border);
color: var(--fg-dimmer); font-size: 11px; color: var(--fg-dimmer); font-size: 11px;
display: flex; flex-direction: column; gap: 4px;
}
.sidebar .footer code { background: transparent; color: var(--fg-dim); padding: 0;
font-size: 11px; word-break: break-all; }
.sidebar .footer .status-row {
display: flex; align-items: center; gap: 8px;
margin-bottom: 4px;
}
.sidebar .footer .status-row .dot {
width: 8px; height: 8px; border-radius: 50%; display: inline-block;
background: var(--fg-dimmer); flex: none;
}
.sidebar .footer .status-row .dot.ok { background: var(--ok);
box-shadow: 0 0 6px color-mix(in srgb, var(--ok) 60%, transparent); }
.sidebar .footer .status-row .dot.err { background: var(--err); }
.sidebar .footer .status-row .dot.warn { background: var(--warn); }
.sidebar .footer .status-label { color: var(--fg-dim); }
.sidebar .footer .status-value { color: var(--fg); font-weight: 600; }
.sidebar .footer .status-value.ok { color: var(--ok); }
.sidebar .footer .status-value.err { color: var(--err); }
.sidebar .footer .status-value.warn { color: var(--warn); }
.sidebar .footer .footer-sub { color: var(--fg-dimmer); margin-top: 2px; }
/* Persistent backend identity. Sits below the advertised URL so even
when an operator has uploaded their own logo, "what is this" stays
answerable from the bottom-left of every page. */
.sidebar .footer .footer-version {
margin-top: 8px; padding-top: 8px;
border-top: 1px dashed var(--border-soft);
color: var(--fg-dim);
font-variant-numeric: tabular-nums;
letter-spacing: 0.2px;
} }
.sidebar .footer code { background: transparent; color: var(--fg-dim); padding: 0; }
/* ── Top bar ───────────────────────────────────────────────────────── */ /* ── Top bar ───────────────────────────────────────────────────────── */
@@ -299,102 +230,34 @@ button, .btn {
cursor: pointer; cursor: pointer;
transition: background 0.12s ease; transition: background 0.12s ease;
} }
button:hover, .btn:hover { background: var(--accent-dim); color: #f4fffd; } button:hover, .btn:hover { background: var(--accent-dim); color: #fff; }
button.ghost { background: transparent; color: var(--fg); border: 1px solid var(--border); } button.ghost { 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 color-mix(in srgb, var(--err) 35%, transparent); } button.danger { background: transparent; color: var(--err); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); }
button.danger:hover { background: color-mix(in srgb, var(--err) 14%, transparent); color: var(--err); } button.danger:hover { background: color-mix(in srgb, var(--err) 14%, transparent); color: var(--err); }
/* v0.5.3: unified spacing for a card's primary action button(s). Any
button that sits as a direct child of a card body (Save, Bind, Add,
Launch, ) gets the same gap above it so it never butts against the
form. Inline buttons inside table rows / toolbars / logo slots /
modal action bars are nested deeper, so the `>` keeps them untouched.
Adjacent action buttons on one row (e.g. Save + Send test) share the
margin and stay aligned. */
.card .body > button { margin-top: 16px; }
label.field { label.field {
display: grid; gap: 4px; margin-bottom: 14px; display: grid; gap: 4px; margin-bottom: 14px;
} }
label.field .name { color: var(--fg-dim); font-size: 12px; } label.field .name { color: var(--fg-dim); font-size: 12px; }
label.field .hint { color: var(--fg-dimmer); font-size: 11px; } label.field .hint { color: var(--fg-dimmer); font-size: 11px; }
/* All single-line inputs share one chrome rule. Pre-v0.4.6 we only label.field input[type="text"],
styled type=text/number, which left type=password fields rendering label.field input[type="number"],
with the default browser look visibly off vs adjacent text fields
in the Account card. The negation list keeps `type=checkbox`,
`type=file`, and `type=range` (none of which we use inside
`label.field`) from picking up the padded-box look. */
label.field input:not([type="checkbox"]):not([type="file"]):not([type="range"]),
label.field select, label.field select,
label.field textarea { label.field textarea {
width: 100%; background: var(--bg); color: var(--fg); width: 100%; background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: var(--radius); border: 1px solid var(--border); border-radius: var(--radius);
padding: 7px 10px; font: inherit; padding: 7px 10px; font: inherit;
/* iOS/Safari shrinks password-field text by default; clamp it so
the password input matches the username input's metrics. */
font-size: 14px; line-height: 1.4;
box-shadow: none; -webkit-appearance: none; appearance: none;
}
/* v0.4.63: with `appearance: none`, the native <select> dropdown arrow
disappears, which makes the "Metadata source" pick-list look like a
plain (and slightly squished) text input. Paint our own chevron via
background-image so the control still reads as a dropdown, and reserve
right-padding for it. The data-URI SVG inherits currentColor via the
`stroke` attribute so the arrow follows light/dark theme without a
second declaration. */
label.field select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%239aa0a6' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><polyline points='1.5,1.5 6,6 10.5,1.5'/></svg>");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 11px 7px;
padding-right: 30px;
}
:root[data-theme="light"] label.field select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%235a6377' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><polyline points='1.5,1.5 6,6 10.5,1.5'/></svg>");
} }
label.field input:focus, label.field select:focus, label.field textarea:focus { label.field input:focus, label.field select:focus, label.field textarea:focus {
outline: none; border-color: var(--accent); outline: none; border-color: var(--accent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
} }
label.check { label.check {
display: flex; gap: 10px; align-items: center; display: flex; gap: 10px; align-items: center;
padding: 8px 10px; margin-bottom: 6px; padding: 8px 10px; margin-bottom: 6px;
border: 1px solid var(--border-soft); border-radius: var(--radius); border: 1px solid var(--border-soft); border-radius: var(--radius);
} }
/* v0.4.63: native checkboxes used to render as opaque black squares in label.check input { accent-color: var(--accent); }
light mode because the page meta declares `color-scheme: dark light`
and `accent-color` alone only repaints the *check mark* (not the
container). Take full control of the chrome so the box reads cleanly
on both palettes and the checked state lights up in our accent. */
label.check input[type="checkbox"] {
appearance: none; -webkit-appearance: none;
width: 16px; height: 16px; flex: none;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
display: inline-grid; place-content: center;
cursor: pointer; margin: 0;
transition: background 0.1s ease, border-color 0.1s ease;
}
label.check input[type="checkbox"]:hover { border-color: var(--accent); }
label.check input[type="checkbox"]:checked {
background: var(--accent);
border-color: var(--accent);
}
label.check input[type="checkbox"]:checked::after {
/* Classic glyph built from a rotated rectangle border. Colour is
#002923 (the same near-black we use on solid-accent buttons) so the
tick stays legible against the teal fill in both themes. */
content: '';
width: 4px; height: 8px;
border: solid #002923;
border-width: 0 2px 2px 0;
transform: rotate(45deg) translate(-1px, -1px);
}
label.check input[type="checkbox"]:focus-visible {
outline: none;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
}
/* ── Drop zone ────────────────────────────────────────────────────── */ /* ── Drop zone ────────────────────────────────────────────────────── */
@@ -418,7 +281,8 @@ label.check input[type="checkbox"]:focus-visible {
/* Imaging progress widget /* Imaging progress widget
Animated brand mark paired with a horizontal progress bar; surfaces Animated brand mark paired with a horizontal progress bar; surfaces
on Dashboard and the Queue tab. */ on Dashboard and the Queue tab. Renamed from `.forge-progress` in
v0.3.0 the anvil-themed naming is gone with the rebrand. */
.queue-progress { .queue-progress {
display: flex; align-items: center; gap: 16px; display: flex; align-items: center; gap: 16px;
padding: 16px; padding: 16px;
@@ -512,23 +376,11 @@ 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. The default is a 4-column grid sized for the /* Inline form rows. */
Account card's "Current / New username / New password / Confirm" .form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; }
quartet; the `.cols-3` modifier swaps to a 3-column layout for the @media (max-width: 900px) { .form-row { grid-template-columns: 1fr; } }
SSO header strip (display name / logo URL / metadata source). All
`.form-row > label.field` children share the same baseline because
their inner inputs share metrics via the global rule above. */
.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; align-items: end; }
.form-row.cols-3 { grid-template-columns: repeat(3, 1fr); }
.form-row.cols-2 { grid-template-columns: repeat(2, 1fr); }
.form-row label.field { margin-bottom: 0; }
@media (max-width: 900px) {
.form-row,
.form-row.cols-3,
.form-row.cols-2 { grid-template-columns: 1fr; }
}
/* ── Queued deployment visual ────────────────────────────────────── */ /* ── Gate queue "horse race" visual ──────────────────────────────── */
.queue-track { .queue-track {
display: grid; gap: 6px; display: grid; gap: 6px;
padding: 10px 0; padding: 10px 0;
@@ -562,366 +414,52 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
min-height: 480px; min-height: 480px;
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
} }
/* Terminal pane colours follow the active theme. Hard-coded hexes
(#050505, #181818, #cfd6e2 etc.) were leaving the light-mode pane
looking dark; we keep palette-aware vars instead so the toggle works. */
.terminal .pane { .terminal .pane {
flex: 1; overflow: auto; flex: 1; overflow: auto;
padding: 10px 14px; padding: 10px 14px;
font-family: var(--mono); font-size: 12.5px; line-height: 1.5; font-family: var(--mono); font-size: 12.5px; line-height: 1.5;
color: var(--fg); color: #cfd6e2;
white-space: pre-wrap; word-break: break-word; white-space: pre-wrap; word-break: break-word;
} }
.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: var(--fg); } .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: var(--accent); } .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: var(--bg-panel-2); 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; }
/* Auth screen (first-run setup + login)
Used when /api/me reports setup_required or !authenticated. The
regular .shell is hidden; this overlay takes the full viewport so
the operator never sees half-loaded dashboard chrome while the auth
state is unknown. Same palette as the rest of the UI borrows the
Sonarr/Radarr layout (centered narrow card on the page background).
*/
.auth-screen {
position: fixed; inset: 0;
display: flex; align-items: center; justify-content: center;
background: var(--bg);
padding: 24px;
z-index: 100;
}
.auth-card {
width: 100%; max-width: 380px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 28px 28px 22px;
}
.auth-card .brand-row {
display: flex; align-items: center; gap: 12px;
margin-bottom: 18px;
}
.auth-card .brand-row img { width: 32px; height: 32px; flex: none; }
.auth-card .brand-row .name { font-size: 17px; font-weight: 600; letter-spacing: 0.2px; color: var(--fg); }
/* v0.5.0: FleetDM-style custom logo on the login/setup card the
uploaded logo spans the card header and the "OpenPXE" wordmark is
dropped (the logo is the brand). Matches the sidebar treatment. */
.auth-card .brand-row.has-custom-logo { justify-content: center; gap: 0; margin-bottom: 22px; }
.auth-card .brand-row.has-custom-logo img {
width: auto; height: 52px; max-width: 240px;
object-fit: contain; object-position: center;
}
.auth-card h2 {
margin: 0 0 6px; font-size: 16px; font-weight: 600; color: var(--fg);
}
.auth-card .lede {
color: var(--fg-dim); font-size: 13px; margin: 0 0 18px;
line-height: 1.5;
}
.auth-card .field { margin-bottom: 12px; }
.auth-card input[type="text"],
.auth-card input[type="password"] {
width: 100%; background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 9px 11px; font: inherit; font-size: 13.5px;
}
.auth-card input:focus { outline: none; border-color: var(--accent); }
.auth-card .submit { width: 100%; padding: 9px 12px; margin-top: 6px; }
.auth-card .auth-err {
margin-top: 12px; color: var(--err); font-size: 12.5px;
}
.auth-card .auth-foot {
margin-top: 14px; padding-top: 12px;
border-top: 1px solid var(--border-soft);
color: var(--fg-dimmer); font-size: 11.5px; text-align: center;
}
.auth-card .sso-btn {
width: 100%; margin-top: 10px;
background: transparent; color: var(--fg);
border: 1px solid var(--border);
padding: 9px 12px;
}
.auth-card .sso-btn:hover {
background: var(--bg-panel-2); border-color: var(--accent); color: var(--fg);
}
.auth-card .sso-btn .meta { color: var(--fg-dim); font-size: 11px; margin-top: 2px; }
/* Top-right user menu (v0.4.6)
The "signed in as X" identity + sign-out moved out of the sidebar
footer in v0.4.6 the sidebar footer is now reserved for the
service-state trio (Service status / Advertised URL / Backend
version). The button matches the theme toggle's size + chrome so
the top-right reads as a tidy two-icon strip. */
.user-menu { position: relative; }
.user-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 36px; height: 32px;
background: transparent; color: var(--fg);
border: 1px solid var(--border); border-radius: 8px;
cursor: pointer; padding: 0;
transition: background 0.15s ease, border-color 0.15s ease;
}
.user-btn:hover { background: var(--bg-panel-2); border-color: var(--accent); }
.user-pop {
position: absolute; right: 0; top: 38px;
min-width: 200px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 6px;
z-index: 60;
display: flex; flex-direction: column; gap: 2px;
}
.user-pop[hidden] { display: none; }
.user-pop .user-pop-name {
padding: 8px 10px 6px;
border-bottom: 1px solid var(--border-soft);
margin-bottom: 4px;
color: var(--fg); font-weight: 600; font-size: 13px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.user-pop .user-pop-item {
text-align: left; width: 100%;
background: transparent; color: var(--fg);
border: 0; border-radius: var(--radius);
padding: 7px 10px; font: inherit; font-size: 13px; font-weight: 500;
cursor: pointer;
}
.user-pop .user-pop-item:hover {
background: var(--bg-panel-2); color: var(--fg);
}
.user-pop .user-pop-danger { color: var(--err); }
.user-pop .user-pop-danger:hover {
background: color-mix(in srgb, var(--err) 12%, transparent);
color: var(--err);
}
/* ── About card ─────────────────────────────────────────────────── */ /* ── About card ─────────────────────────────────────────────────── */
.about-hero { padding: 20px 24px; } .about-hero { padding: 20px 24px; }
.about-hero h2 { font-size: 22px; margin: 0 0 8px; color: var(--fg); } .about-hero h2 { font-size: 22px; margin: 0 0 8px; color: var(--fg); }
/* Span the full main column rather than capping at 60ch the page is .about-hero .lead { color: var(--fg-dim); font-size: 14px; max-width: 60ch; }
read at typical desktop widths and the cap was leaving the right two
thirds of the panel awkwardly empty. */
.about-hero .lead { color: var(--fg-dim); font-size: 14px; max-width: none; }
.about-hero .who { margin-top: 18px; font-size: 13px; } .about-hero .who { margin-top: 18px; font-size: 13px; }
.about-hero .who span { color: var(--fg-dim); } .about-hero .who span { color: var(--fg-dim); }
.about-hero .who strong { color: var(--accent); } .about-hero .who strong { color: var(--accent); }
.about-hero a { color: var(--accent); }
/* ── API reference (Settings → bottom) ─────────────────────────── */
.api-ref { display: grid; gap: 18px; padding: 16px; }
.api-ref .group h3 {
margin: 0 0 8px; font-size: 13px; color: var(--fg-dim);
text-transform: uppercase; letter-spacing: 0.8px;
}
.api-ref .ep {
display: grid; grid-template-columns: 64px minmax(200px, 1fr) 2fr;
gap: 12px; align-items: baseline;
padding: 6px 0; border-top: 1px solid var(--border-soft);
font-size: 13px;
}
.api-ref .ep:first-child { border-top: 0; }
.api-ref .ep .method {
font-family: var(--mono); font-weight: 600; font-size: 11px;
padding: 2px 6px; border-radius: 4px;
text-align: center; letter-spacing: 0.6px;
}
.api-ref .ep .method.get { background: color-mix(in srgb, var(--ok) 22%, transparent); color: var(--ok); }
.api-ref .ep .method.post { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--accent); }
.api-ref .ep .method.put { background: color-mix(in srgb, var(--warn) 22%, transparent); color: var(--warn); }
.api-ref .ep .method.delete { background: color-mix(in srgb, var(--err) 22%, transparent); color: var(--err); }
.api-ref .ep .path { font-family: var(--mono); color: var(--fg); word-break: break-all; }
.api-ref .ep .desc { color: var(--fg-dim); }
@media (max-width: 900px) {
.api-ref .ep { grid-template-columns: 1fr; gap: 4px; }
.api-ref .ep .method { justify-self: start; }
}
/* ── Disk space card ───────────────────────────────────────────── */
.diskbar {
height: 10px; border-radius: 5px;
background: var(--bg-elev);
overflow: hidden; margin-top: 8px;
}
.diskbar .fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-dim), var(--accent));
transition: width 0.4s ease;
}
.diskbar.warn .fill { background: var(--warn); }
.diskbar.full .fill { background: var(--err); }
.disk-meta { display: flex; gap: 14px; font-size: 12px; color: var(--fg-dim); margin-top: 8px; flex-wrap: wrap; }
.disk-meta strong { color: var(--fg); font-weight: 600; font-variant-numeric: tabular-nums; }
/* ── Logo upload (Settings) ────────────────────────────────────── */
.logo-preview {
display: flex; align-items: center; gap: 14px;
padding: 12px;
background: var(--bg-panel-2);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.logo-preview .swatch {
width: 56px; height: 56px;
display: flex; align-items: center; justify-content: center;
background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius);
flex: none;
}
.logo-preview .swatch img { max-width: 48px; max-height: 48px; }
.logo-preview .info { flex: 1; min-width: 0; }
.logo-preview .info .name { color: var(--fg); font-weight: 600; }
.logo-preview .info .meta { color: var(--fg-dim); font-size: 12px; margin-top: 2px; }
/* v0.5.1: collapsible "Advanced" disclosure at the bottom of Settings
(the former Advanced sidebar tab). A quiet, full-width toggle that
expands to reveal the notification + API-reference cards. */
.advanced-disclosure { width: 100%; }
.advanced-summary {
list-style: none;
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
color: var(--fg-dim);
font-size: 13px;
font-weight: 600;
background: var(--bg-panel-2);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.advanced-summary:hover { color: var(--fg); }
.advanced-summary::-webkit-details-marker { display: none; }
.advanced-summary::before {
content: "▸";
font-size: 11px;
transition: transform 0.15s ease;
}
.advanced-disclosure[open] .advanced-summary::before { transform: rotate(90deg); }
/* v0.5.1: protocol tag on a unified Remote-shares row (SMB / NFS). */
.proto-badge {
display: inline-block;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
padding: 1px 6px;
margin-right: 8px;
border-radius: 4px;
vertical-align: middle;
background: var(--bg-panel-2);
border: 1px solid var(--border);
color: var(--fg-dim);
}
/* ── v0.5.2: three-slot branding (light / dark / client) ─────────── */
.logo-slots {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
@media (max-width: 720px) { .logo-slots { grid-template-columns: 1fr; } }
.logo-slot {
display: flex; flex-direction: column; gap: 8px;
padding: 12px;
background: var(--bg-panel-2);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.logo-slot-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.logo-slot-head .name { color: var(--fg); font-weight: 600; font-size: 13px; }
.logo-slot .swatch {
height: 64px;
display: flex; align-items: center; justify-content: center;
background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius);
}
.logo-slot .swatch img { max-width: 90%; max-height: 52px; object-fit: contain; }
.logo-slot-hint { color: var(--fg-dim); font-size: 11.5px; }
/* ── v0.5.2: login local/SSO separation ─────────────────────────── */
.auth-card .auth-divider {
display: flex; align-items: center; text-align: center;
color: var(--fg-dimmer); font-size: 11px; text-transform: uppercase;
letter-spacing: 0.08em;
margin: 16px 0 12px;
}
.auth-card .auth-divider::before,
.auth-card .auth-divider::after {
content: ""; flex: 1; height: 1px; background: var(--border-soft);
}
.auth-card .auth-divider span { padding: 0 10px; }
.auth-card .sso-block .sso-btn { margin-top: 0; }
.auth-card .sso-btn {
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.auth-card .sso-btn .sso-logo { width: 16px; height: 16px; object-fit: contain; flex: none; }
/* ── v0.5.2: modal (queue Profile editor) ───────────────────────── */
.modal-overlay {
position: fixed; inset: 0; z-index: 200;
display: flex; align-items: center; justify-content: center;
background: rgba(0, 0, 0, 0.55);
padding: 24px;
}
.modal-box {
width: 100%; max-width: 520px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 22px;
}
.modal-box h2 { margin: 0 0 14px; font-size: 16px; font-weight: 600; color: var(--fg); }
.modal-actions {
display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px;
}
.modal-actions .submit { width: auto; padding: 8px 18px; }
/* v0.7.2: a label.field directly followed by the card's action button
stacked its own 14px bottom margin onto the button's 16px top margin
(30px total) visible on Queue "Launch for all waiting" and the
Network "Save". Collapse the doubled gap so every primary action sits
the same 16px below its form. */
.card .body > label.field:has(+ button) { margin-bottom: 0; }
/* v0.7.2: inline list filter above a table (Available images). The input
is wrapped in a label.field so it borrows the standard text-field chrome
and matches every other input in the app; this wrapper just insets it
from the card edges so it lines up with the header text above. */
.list-search { padding: 14px 16px; }
+206 -2077
View File
File diff suppressed because it is too large Load Diff
+11 -48
View File
@@ -5,18 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" /> <meta name="color-scheme" content="dark light" />
<title>OpenPXE</title> <title>OpenPXE</title>
<!-- v0.4.61: the `?v=…` query string is replaced by the server at <link rel="stylesheet" href="/assets/app.css" />
request time with the running OpenPXE version. That guarantees a <link rel="icon" type="image/svg+xml" href="/assets/logo.svg" />
fresh URL on every upgrade so browsers (and intermediary proxies)
can't keep serving stale JS / CSS / branding from before the
deploy. Combined with `Cache-Control: no-cache, must-revalidate`
on the asset handlers, the practical caching window is one
version. -->
<link rel="stylesheet" href="/assets/app.css?v={{ASSET_VERSION}}" />
<!-- v0.5.2: favicon is pinned to the bundled OpenPXE mark (its own
endpoint, decoupled from operator branding) for tab-icon
continuity regardless of any uploaded light/dark/client logo. -->
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg?v={{ASSET_VERSION}}" />
<!-- Theme is read from localStorage *before* paint to avoid the <!-- Theme is read from localStorage *before* paint to avoid the
dark→light flash on every navigation. Falls back to the OS dark→light flash on every navigation. Falls back to the OS
preference and finally to dark. --> preference and finally to dark. -->
@@ -35,9 +25,12 @@
<body> <body>
<div class="shell"> <div class="shell">
<aside class="sidebar"> <aside class="sidebar">
<div class="{{BRAND_CLASS}}"> <div class="brand">
<img src="/assets/logo.svg?v={{ASSET_VERSION}}&r={{LOGO_REV}}" alt="OpenPXE" /> <img src="/assets/logo.svg" alt="" />
<strong>OpenPXE</strong> <div>
<strong>OpenPXE</strong>
<div class="sub">v<span data-bind="version">0.3.0</span></div>
</div>
</div> </div>
<nav> <nav>
<a data-view="dashboard" class="active">Dashboard</a> <a data-view="dashboard" class="active">Dashboard</a>
@@ -55,27 +48,18 @@
<span class="count" data-bind="host_count">0</span> <span class="count" data-bind="host_count">0</span>
</a> </a>
<a data-view="terminal">Terminal</a> <a data-view="terminal">Terminal</a>
<a data-view="settings">Settings</a>
<a data-view="about">About</a> <a data-view="about">About</a>
</nav> </nav>
<div class="footer"> <div class="footer">
<div class="status-row"> Advertised to clients<br/>
<span class="dot" data-bind="ready_dot" title="Server readiness"></span>
<span class="status-label">Service status:</span>
<span class="status-value" data-bind="ready_label">checking…</span>
</div>
<div class="footer-sub">Advertised to clients</div>
<code>{{BASE_URL}}</code> <code>{{BASE_URL}}</code>
<!-- The brand badge at the top can be overridden by operator-uploaded
logos; keep "OpenPXE v…" pinned in the footer so the backend
identity is always visible regardless of branding. -->
<div class="footer-version">OpenPXE&nbsp;v<span data-bind="version">0.4.63</span></div>
</div> </div>
</aside> </aside>
<header class="topbar"> <header class="topbar">
<h1 data-bind="view_title">Dashboard</h1> <h1 data-bind="view_title">Dashboard</h1>
<div class="spacer"></div> <div class="spacer"></div>
<span class="chip" data-bind="ready_chip" title="Server readiness">checking…</span>
<span class="chip"><strong data-bind="iso_count2">0</strong>&nbsp;images</span> <span class="chip"><strong data-bind="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="queue_count2">0</strong>&nbsp;in queue</span> <span class="chip"><strong data-bind="queue_count2">0</strong>&nbsp;in queue</span>
@@ -99,32 +83,11 @@
<path d="M20.5 14A8 8 0 0 1 10 3.5 a8 8 0 1 0 10.5 10.5z"/> <path d="M20.5 14A8 8 0 0 1 10 3.5 a8 8 0 1 0 10.5 10.5z"/>
</svg> </svg>
</button> </button>
<!-- v0.4.6: signed-in operator menu. Sits next to the theme toggle
in the top-right corner so the sidebar footer stays clean for
the "Service status / Advertised URL / Backend version" trio.
The whole block is hidden until /api/me confirms a session. -->
<div class="user-menu" data-bind="user_menu_wrap" style="display:none">
<button id="user-menu-btn" class="user-btn" type="button"
aria-label="Account menu" aria-haspopup="true" aria-expanded="false"
title="Account">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="8" r="3.6"/>
<path d="M4.5 20a7.5 7.5 0 0 1 15 0"/>
</svg>
</button>
<div id="user-menu-pop" class="user-pop" data-bind="user_menu_pop" hidden>
<div class="user-pop-name" data-bind="user_pop_name"></div>
<button type="button" class="user-pop-item" data-bind="user_pop_edit">Edit account</button>
<button type="button" class="user-pop-item user-pop-danger" data-bind="user_pop_logout">Sign out</button>
</div>
</div>
</header> </header>
<main class="main" id="view-root"></main> <main class="main" id="view-root"></main>
</div> </div>
<script src="/assets/app.js?v={{ASSET_VERSION}}"></script> <script src="/assets/app.js"></script>
</body> </body>
</html> </html>
+13 -54
View File
@@ -7,71 +7,30 @@
//! nav, top bar with secondary tabs, card-dense content panels. //! nav, top bar with secondary tabs, card-dense content panels.
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
/// Render the top-level page. /// Render the top-level page. `base_url` is interpolated into the footer
/// /// so operators can see at a glance what URL clients are PXE-booting from.
/// * `base_url` is interpolated into the footer so operators can see at
/// a glance what URL clients are PXE-booting from.
/// * `asset_version` is appended as `?v=…` to every asset URL so each
/// release ships with brand-new asset URLs — browsers (and any
/// intermediary proxy) can't keep serving last release's `app.js`
/// when we know the new one is incompatible. Combined with
/// `Cache-Control: no-cache, must-revalidate` on the asset handlers,
/// the worst-case caching window is one version.
/// * `logo_rev` is appended to the brand-mark and favicon URLs as an
/// extra `&r=…` token. Unlike `asset_version` it changes every time
/// the operator swaps the custom logo, so the top-left mark updates
/// immediately on the next page load instead of being pinned to the
/// release version (which only changes on upgrade). `index.html`
/// itself is served `no-cache`, so the fresh token lands as soon as
/// the operator reloads after an upload.
/// * `has_custom_logo` switches the sidebar brand block between the
/// bundled mark + "OpenPXE" wordmark (false) and a FleetDM-style
/// full-width custom logo with the wordmark hidden (true). Rendered
/// server-side so there's no flash of the default mark before JS runs.
#[must_use] #[must_use]
pub fn index_html( pub fn index_html(base_url: &str) -> String {
base_url: &str, INDEX_HTML.replace("{{BASE_URL}}", base_url)
asset_version: &str,
logo_rev: u64,
has_custom_logo: bool,
) -> String {
let brand_class = if has_custom_logo {
"brand has-custom-logo"
} else {
"brand"
};
INDEX_HTML
.replace("{{BASE_URL}}", base_url)
.replace("{{ASSET_VERSION}}", asset_version)
.replace("{{LOGO_REV}}", &logo_rev.to_string())
.replace("{{BRAND_CLASS}}", brand_class)
} }
#[must_use] #[must_use]
pub fn app_js() -> &'static str { pub fn app_js() -> &'static str { APP_JS }
APP_JS
}
#[must_use] #[must_use]
pub fn app_css() -> &'static str { pub fn app_css() -> &'static str { APP_CSS }
APP_CSS
}
#[must_use] #[must_use]
pub fn logo_svg() -> &'static str { pub fn logo_svg() -> &'static str { LOGO_SVG }
LOGO_SVG
}
/// Larger, faster-cycling rainbow disc — used for the page-load /// Larger, faster-cycling rainbow disc — used for the page-load
/// transition and the imaging-progress widget on Dashboard / Queue. /// transition and the imaging-progress widget on Dashboard / Queue.
/// Pure SVG + SMIL, no JS, no GIF. /// Pure SVG + SMIL, no JS, no GIF.
#[must_use] #[must_use]
pub fn loader_svg() -> &'static str { pub fn loader_svg() -> &'static str { LOADER_SVG }
LOADER_SVG
}
const INDEX_HTML: &str = include_str!("index.html"); const INDEX_HTML: &str = include_str!("index.html");
const APP_CSS: &str = include_str!("app.css"); const APP_CSS: &str = include_str!("app.css");
const APP_JS: &str = include_str!("app.js"); const APP_JS: &str = include_str!("app.js");
const LOGO_SVG: &str = include_str!("logo.svg"); const LOGO_SVG: &str = include_str!("logo.svg");
const LOADER_SVG: &str = include_str!("loader.svg"); const LOADER_SVG: &str = include_str!("loader.svg");
+33 -126
View File
@@ -14,112 +14,37 @@
# 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
# spends most of its life idle. # spends most of its life idle.
ARG RUST_VERSION=1.95 ARG RUST_VERSION=1.82
########## fetch iPXE binaries + wimboot ########## ########## fetch iPXE binaries ##########
# Pulls the upstream boot.ipxe.org pre-builds (no PNG support) plus
# wimboot. These cover the arches we don't build from source here:
# BIOS undionly.kpxe and i386-efi (which need a 32-bit x86 toolchain),
# and serve as the baseline that the PNG-enabled x86_64/arm64 UEFI
# binaries from the `ipxe-build` stage overlay on top of.
FROM debian:12-slim AS fetch FROM debian:12-slim AS fetch
# rpm2cpio + cpio: extract Fedora's Microsoft-signed shim/GRUB RPMs for RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
# the Secure Boot chain (v0.7.0, scripts/fetch-shim.sh).
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates rpm2cpio cpio \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /src WORKDIR /src
COPY scripts/fetch-ipxe.sh scripts/fetch-ipxe.sh COPY scripts/fetch-ipxe.sh scripts/fetch-ipxe.sh
COPY scripts/fetch-shim.sh scripts/fetch-shim.sh
RUN mkdir -p assets/ipxe && bash scripts/fetch-ipxe.sh RUN mkdir -p assets/ipxe && bash scripts/fetch-ipxe.sh
# Signed shim+GRUB (Secure Boot escalation rung). Redistributed
# unmodified from the official Fedora packages — see fetch-shim.sh for
# the trust model.
RUN bash scripts/fetch-shim.sh /src/assets/ipxe
########## build PNG-enabled iPXE from source ##########
# v0.4.69: THE graphical-boot-menu unlock. iVentoy paints a PNG
# background on the PXE screen using stock iPXE built with
# CONSOLE_FRAMEBUFFER + IMAGE_PNG + CONSOLE_CMD; the public iPXE
# binaries omit those, so `console --picture` is a no-op on them.
# We build our own from upstream with that thin config delta.
#
# The historical blocker was cc1 segfaulting when an amd64 gcc ran
# under QEMU emulation on an arm64 host. The fix: pin this stage to
# $BUILDPLATFORM (the NATIVE builder arch — arm64 on an Apple-Silicon
# Mac, amd64 in x86 CI) and cross-compile with a real cross toolchain
# (CROSS_COMPILE=x86_64-linux-gnu-). The compiler runs native and
# emits x86_64 — no emulation, no segfault. arm64-efi builds natively.
FROM --platform=$BUILDPLATFORM debian:12-slim AS ipxe-build
# libc6-dev is REQUIRED and easy to miss under --no-install-recommends:
# iPXE's host utilities (elf2efi, zbin) compile with the native gcc and
# pull <stdint.h>; without the native libc headers gcc's #include_next
# falls through to iPXE's freestanding headers and dies on bits/stdint.h.
# The target (iPXE firmware) code is -ffreestanding/-nostdinc, so the
# x86_64 cross toolchain needs NO cross libc headers.
RUN apt-get update && apt-get install -y --no-install-recommends \
git make perl gcc binutils libc6-dev \
gcc-x86-64-linux-gnu binutils-x86-64-linux-gnu \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY scripts/build-ipxe.sh scripts/build-ipxe.sh
COPY deploy/ipxe/local/ deploy/ipxe/local/
RUN mkdir -p assets/ipxe && bash scripts/build-ipxe.sh /src/assets/ipxe
########## build openpxe ########## ########## build openpxe ##########
# v0.5.2: cross-compile the Rust binary NATIVELY — no QEMU. FROM rust:${RUST_VERSION}-bookworm AS build
#
# This stage is pinned to $BUILDPLATFORM (the builder's native arch — arm64
# on an Apple-Silicon Mac, amd64 in x86 CI), exactly like `ipxe-build`. The
# Rust compiler therefore runs at full native speed and emits an
# x86_64-unknown-linux-musl binary via `cargo-zigbuild`, which uses `zig cc`
# as the cross-linker (it bundles the musl sysroot for every target, so
# there's no fiddly cross-gcc toolchain to assemble).
#
# Why this replaced the old `FROM rust ... --platform=linux/amd64` build:
# that ran the *entire* compiler under QEMU x86_64 emulation on the arm64
# host. It was ~15x slower (a single crate took >20 min) and the emulated
# gcc/linker intermittently SIGSEGV'd or hung mid-link. Cross-compiling
# sidesteps emulation entirely — the build is minutes, not half an hour,
# and is deterministic.
#
# The output is still a fully static musl binary with no glibc dependency,
# so the runtime stage stays free to be any Linux distro.
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-bookworm AS build
WORKDIR /src WORKDIR /src
# zig (via the `ziglang` pip package — cargo-zigbuild auto-discovers it as
# `python3 -m ziglang`) supplies the x86_64 musl sysroot + linker.
# cargo-zigbuild is the thin cargo wrapper that wires zig in as the linker.
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 python3-pip \
&& rm -rf /var/lib/apt/lists/* \
&& rustup target add x86_64-unknown-linux-musl \
&& pip3 install --no-cache-dir --break-system-packages ziglang \
&& cargo install --locked cargo-zigbuild
# Do not copy rust-toolchain.toml into the image. The local workspace pins # Copy the whole workspace in one go. We used to do a two-pass "cache-prime
# developer tooling, but inside Docker we intentionally use the Rust version # with stubs, then real build" dance for dep-compile reuse; that turned out
# selected by the base image. Copying rust-toolchain.toml with # to silently serve stale stub binaries when cargo's fingerprint didn't
# `channel = "stable"` makes rustup download a second full toolchain during # notice the source swap. A single build is ~1.5 min longer on cold cache
# the build, which is slow and can exhaust small Colima/CI disks. # but guarantees the binary reflects the sources we copied.
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml rust-toolchain.toml ./
COPY crates/ crates/ COPY crates/ crates/
# Baseline binaries (BIOS / i386 / wimboot), then overlay the
# PNG-enabled x86_64 + arm64 UEFI binaries built from source. The
# overlay wins for snponly.efi / ipxe.efi / snponly-arm64.efi so the
# common modern clients get the graphical background; the rest keep the
# upstream no-PNG binaries and the menu's `|| console` text fallback.
COPY --from=fetch /src/assets/ipxe /src/assets/ipxe COPY --from=fetch /src/assets/ipxe /src/assets/ipxe
COPY --from=ipxe-build /src/assets/ipxe/snponly.efi /src/assets/ipxe/snponly.efi
COPY --from=ipxe-build /src/assets/ipxe/ipxe.efi /src/assets/ipxe/ipxe.efi
# Cache cargo registry + target across builds. `cargo zigbuild` runs the # Cache cargo registry + target across builds. The `--no-edit` touch is
# native rustc (fast) and links for x86_64-musl with zig — no emulation. # belt-and-suspenders: cargo occasionally misses mtime-only changes on
# networked FS; this forces a fingerprint check.
RUN --mount=type=cache,target=/usr/local/cargo/registry \ 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 \
cargo zigbuild --release --target x86_64-unknown-linux-musl --bin openpxe && \ find crates -name '*.rs' -exec touch {} + && \
cp target/x86_64-unknown-linux-musl/release/openpxe /openpxe && \ cargo build --release --bin openpxe && \
cp target/release/openpxe /openpxe && \
ls -l /openpxe ls -l /openpxe
########## runtime ########## ########## runtime ##########
@@ -127,45 +52,27 @@ FROM debian:12-slim AS runtime
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
ca-certificates libcap2-bin tini gosu iproute2 \ ca-certificates libcap2-bin tini gosu iproute2 \
wimtools samba smbclient \ wimtools samba nfs-common \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10001 --home-dir /var/lib/openpxe --shell /usr/sbin/nologin openpxe \ && useradd --system --uid 10001 --home-dir /var/lib/openpxe --shell /usr/sbin/nologin openpxe \
&& mkdir -p /var/lib/openpxe/isos /var/lib/openpxe/work /var/lib/openpxe/smb \ && mkdir -p /var/lib/openpxe/isos /var/lib/openpxe/work /var/lib/openpxe/smb \
&& chown -R openpxe:openpxe /var/lib/openpxe && chown -R openpxe:openpxe /var/lib/openpxe
# v0.4.5: the openpxe binary itself is now built against musl and is # Runtime deps explained:
# fully static — no glibc dependency. The runtime stage still ships # wimtools - provides `wimlib-imagex`, used to inject startnet.cmd into boot.wim.
# Debian slim because OpenPXE shells out to the packages below for # samba - `smbd` serves extracted Windows install media on :445 for WinPE
# functionality we deliberately don't reimplement in-process: # to `net use`. Guest read-only, scoped to /var/lib/openpxe/smb.
# # nfs-common - provides `mount.nfs` / `mount.nfs4` for the Storage tab's
# wimtools - `wimlib-imagex`, used to inject startnet.cmd into boot.wim. # NFS share manager. Mount also requires the container to run
# samba - `smbd` serves extracted Windows install media on :445 so # with CAP_SYS_ADMIN — without it, mount(2) returns EPERM and
# WinPE can `net use`. Guest read-only, scoped to # the manager surfaces a clear error in the UI instead of
# /var/lib/openpxe/smb. This package provides the SERVER # failing silently.
# side only; the client CLI is a separate package below. # iproute2 - `ip addr` / `ip route` for the auto-detected Network tab
# smbclient - v0.4.66: Samba's `smbclient` userspace CLI, used by # fields (NIC name, subnet mask, default gateway). Tiny,
# the Storage tab's SMB shares manager to list and stream # always available; we don't pull in netlink crates for
# ISOs from remote SMB servers without ever mounting them # this one-shot startup probe.
# in the kernel. In Debian 12 `smbclient` is NOT pulled # gosu - drops privileges cleanly from root after the entrypoint fixes
# in by the `samba` package — they're siblings, not # bind-mount ownership (common OpenShift/Docker UX issue).
# parent/child. v0.4.65 shipped without this line and # Windows-specific tools only activate when the WebUI toggle is on.
# every "Add share" attempt surfaced
# `could not exec smbclient: No such file or directory`
# until this landed.
# iproute2 - `ip addr` / `ip route` for the auto-detected Network
# tab fields (NIC name, subnet mask, default gateway).
# Tiny, always available; we don't pull in netlink crates
# for this one-shot startup probe.
# gosu - drops privileges cleanly from root after the entrypoint
# fixes bind-mount ownership (common OpenShift/Docker UX
# issue).
#
# v0.4.65 dropped `nfs-common` — kernel-mount NFS is gone. The SMB
# shares replacement uses userspace `smbclient` and needs no kernel
# helpers.
#
# A future "openpxe-static" variant could drop everything except the
# binary onto distroless once we move the Windows + SMB legs to
# in-process Rust crates.
COPY --from=build /openpxe /usr/local/bin/openpxe 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
-14
View File
@@ -1,14 +0,0 @@
/*
* OpenPXE iPXE build override console options.
*
* Included at the end of config/console.h. CONSOLE_FRAMEBUFFER is the
* unified graphical framebuffer console (EFI GOP on UEFI, VESA on
* BIOS); it's what `console --picture` paints into. This is the same
* single flag iVentoy enables for its graphical PXE screen.
*
* We *add* the framebuffer console rather than replacing the default
* EFI/BIOS text consoles, so text output still works before/after the
* picture is set.
*/
#define CONSOLE_FRAMEBUFFER
-23
View File
@@ -1,23 +0,0 @@
/*
* OpenPXE iPXE build override general options.
*
* iPXE includes <config/local/general.h> at the end of config/general.h,
* so anything defined here is layered on top of the stock defaults
* without editing upstream files. We enable exactly the features the
* graphical PXE boot menu needs:
*
* IMAGE_PNG - PNG decoder, so `console --picture <png>` can paint
* the operator's logo / OpenPXE background.
* IMAGE_PNM - Netpbm decoder (cheap; harmless belt-and-suspenders).
* CONSOLE_CMD - the `console` command itself. Without it you get
* "console: command not found" even with a framebuffer.
*
* (CONSOLE_FRAMEBUFFER lives in config/local/console.h.)
*
* Everything else stays at upstream defaults we are intentionally a
* thin, auditable delta over stock iPXE so the UBDL/GPL story is simple.
*/
#define IMAGE_PNG
#define IMAGE_PNM
#define CONSOLE_CMD
+1 -1
View File
@@ -34,7 +34,7 @@ spec:
fsGroup: 10001 fsGroup: 10001
containers: containers:
- name: openpxe - name: openpxe
image: gitea.milesward.dev/mward4/openpxe:0.4.1 image: ghcr.io/casperadmin/openpxe:0.1.0
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- name: dhcp - name: dhcp
+7 -7
View File
@@ -6,7 +6,7 @@ boot from OpenPXE". Pick the one that matches what you have.
## Path A — build on Unraid, push to Gitea registry, pull by tag ## Path A — build on Unraid, push to Gitea registry, pull by tag
Recommended once you've done it once. Image is published to Recommended once you've done it once. Image is published to
`gitea.milesward.dev/mward4/openpxe:0.4.1` (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:
@@ -40,14 +40,14 @@ What it does:
3. `docker build` against `deploy/docker/Dockerfile`. 3. `docker build` against `deploy/docker/Dockerfile`.
4. `docker login gitea.milesward.dev:3000` using a temp `DOCKER_CONFIG` 4. `docker login gitea.milesward.dev:3000` using a temp `DOCKER_CONFIG`
so the credential never lands in your real `~/.docker/config.json`. so the credential never lands in your real `~/.docker/config.json`.
5. `docker push` both `:0.4.1` and `:latest`. 5. `docker push` both `:0.1.0` and `:latest`.
6. Logout, scrub the temp config, delete the workspace. 6. Logout, scrub the temp config, delete the workspace.
After it finishes, in Unraid → Docker → Add Container, set: After it finishes, in Unraid → Docker → Add Container, set:
| Field | Value | | Field | Value |
|------------|-------------------------------------------------| |------------|-------------------------------------------------|
| Repository | `gitea.milesward.dev/mward4/openpxe:0.4.1` | | 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` |
@@ -88,14 +88,14 @@ then:
```bash ```bash
# On the build host # On the build host
docker save openpxe:0.4.1 | gzip > openpxe-0.4.1.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 openpxe-0.4.1.tar.gz root@unraid:/tmp/ scp openpxe-0.1.0.tar.gz root@unraid:/tmp/
# On Unraid # On Unraid
gunzip -c /tmp/openpxe-0.4.1.tar.gz | docker load gunzip -c /tmp/openpxe-0.1.0.tar.gz | docker load
docker tag openpxe:0.4.1 gitea.milesward.dev/mward4/openpxe:0.4.1 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
+2 -2
View File
@@ -34,8 +34,8 @@
Air-gapped network PXE boot server. Container-native Rust Air-gapped network PXE boot server. Container-native Rust
implementation — DHCP proxy + TFTP + iPXE chainload + HTTP ISO implementation — DHCP proxy + TFTP + iPXE chainload + HTTP ISO
streaming, all in one process. Web UI for ISO upload, NFS share streaming, all in one process. Web UI for ISO upload, NFS share
mounting, and Queued Deployment for coordinated launch of one ISO mounting, and Queued Deployment ("horse-race" simultaneous launch
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,
no testsigning toggle, no httpdisk.sys. Windows boot uses vanilla no testsigning toggle, no httpdisk.sys. Windows boot uses vanilla
+2 -2
View File
@@ -1,7 +1,7 @@
# Phase 6 — recommendations # Phase 6 — recommendations
The v0.4.1 cut leaves OpenPXE in a state where the entire protocol stack The v0.2.0 cut leaves OpenPXE in a state where the entire protocol stack
and operator UI are exercised by the automated test suite, the container is 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 multi-arch buildable, and the image ships at ~97 MB. What's left before
this looks and feels like a 1.0 product is mostly **real-hardware this looks and feels like a 1.0 product is mostly **real-hardware
validation** plus a small batch of features that can only sensibly be validation** plus a small batch of features that can only sensibly be
+12 -11
View File
@@ -265,9 +265,10 @@ tab is one click from the brand bar.
- Persisted to `<work_dir>/hosts.json`. Like `SettingsStore`, in-memory - Persisted to `<work_dir>/hosts.json`. Like `SettingsStore`, in-memory
is authoritative — disk corruption falls back to empty rather than is authoritative — disk corruption falls back to empty rather than
failing startup. failing startup.
- The DHCP reply embeds `?mac=${mac}` in the boot.ipxe URL; iPXE - Inspired by Tinkerbell `smee`'s MAC-prepended URL pattern. The DHCP
substitutes the literal MAC client-side, so the HTTP layer can reply now embeds `?mac=${mac}` in the boot.ipxe URL; iPXE substitutes
short-circuit past the menu when a binding exists. 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. - `/api/hosts` GET / POST / DELETE drives the **Hosts** tab.
**Prometheus metrics** (`crates/core/src/metrics.rs`): **Prometheus metrics** (`crates/core/src/metrics.rs`):
@@ -287,17 +288,18 @@ warning-free. Replaced `format!()`-into-`String` with
`Reverse`, fixed `map_or(false, …)``is_some_and`, and a handful of `Reverse`, fixed `map_or(false, …)``is_some_and`, and a handful of
other idiom fixes. other idiom fixes.
**UI overhaul** for the pre-beta milestone: **UI overhaul** for the v0.2.0 pre-beta milestone:
- Light + dark themes via `:root[data-theme=light]` token swap. - Light + dark themes via `:root[data-theme=light]` token swap.
Toggled by a top-right button or the `T` key. Persisted in Toggled by a top-right button or the `T` key. Persisted in
localStorage; pre-paint inline script avoids dark→light flash. localStorage; pre-paint inline script avoids dark→light flash.
- New SVG logos: a refined OpenPXE mark (`logo.svg`) and a compact - New SVG logos: a refined anvil (`logo.svg`) and a SMIL-animated
SMIL-animated loader (`loader.svg`). Pure SVG, embedded in the binary. `anvil-forge.svg` (rising sparks + pulsing underglow). Pure SVG —
- Deployment progress widget on the Dashboard and Queue: animated no GIFs, no CSS keyframes for the sparks.
OpenPXE mark paired with a `linear-gradient(warn → accent)` progress bar - "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 with a moving sheen. Goes idle (greyscale, no sheen) at zero
imaging load. imaging load.
- Loader replaced "Loading..." text with the same OpenPXE mark. - Loader replaced "Loading" text with the same anvil.
- Sidebar gains a **Hosts** tab. - Sidebar gains a **Hosts** tab.
**Windows boot validation**: **Windows boot validation**:
@@ -315,8 +317,7 @@ other idiom fixes.
fixes: explicit `net start Workstation` before `net use`, surfaces fixes: explicit `net start Workstation` before `net use`, surfaces
errors instead of blind retries. errors instead of blind retries.
**Test posture**: protocol, HTTP, ISO-store, Windows script, queue, metrics, **Test count**: 66 → up from 56 in v0.1.0.
and UI-offline checks all run in the workspace test suite.
## What's deferred to Phase 6 ## What's deferred to Phase 6
-21
View File
@@ -1,21 +0,0 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OpenPXE">
<title>OpenPXE</title>
<!-- Static README mark: the "rainbow-horizon" medallion from the web UI,
with the SMIL animation removed so it renders reliably as an <img>
on Gitea/GitHub. -->
<defs>
<linearGradient id="opxRainbow" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#330f1f"/>
<stop offset="12.56%" stop-color="#c83228"/>
<stop offset="25.06%" stop-color="#fb8841"/>
<stop offset="37.56%" stop-color="#d3dd92"/>
<stop offset="50.06%" stop-color="#59824f"/>
<stop offset="62.06%" stop-color="#002414"/>
<stop offset="74.06%" stop-color="#00143d"/>
<stop offset="86.06%" stop-color="#2874d7"/>
<stop offset="100%" stop-color="#99c2ff"/>
</linearGradient>
</defs>
<circle cx="12" cy="12" r="10.5" fill="url(#opxRainbow)"
stroke="rgba(0,0,0,0.18)" stroke-width="0.6"/>
</svg>

Before

Width:  |  Height:  |  Size: 984 B

-106
View File
@@ -1,106 +0,0 @@
# PXE menu theme — research for next-release follow-up
Status: queued. v0.4.63 keeps the ASCII-banner fallback + `console --picture`
compositor wired; this note captures the design for the menu-theming work
that lands once iPXE rebuilt with `IMAGE_PNG` is published.
## How iVentoy actually does it
iVentoy is closed-source for its menu, but the supporting bits are
public at https://github.com/ventoy/PXE — a vanilla iPXE snapshot
(`iPXE/ipxe-bd13697`) used to produce the loader binaries iVentoy
serves over TFTP (`pxeboot.efi`, `iventoy_loader_16000`,
`iventoy_loader_16000_uefi`).
The graphical menu itself is rendered by iPXE's framebuffer console
with a baked-in PNG background via `console --picture` — same
primitive OpenPXE already uses in `crates/http-api/src/ipxe_script.rs`.
Evidence:
- The iPXE build in `ventoy/PXE` is configured with `CONSOLE_FRAMEBUFFER`
+ `IMAGE_PNG` + `CONSOLE_CMD` (the three flags `console --picture`
needs).
- iVentoy issue #11 confirms "iventoy using default 1024x768"; users
report 800x600 / 1024x768 / 1280x720 / 1280x1024 / 1920x1080 as
selectable resolutions from the iVentoy web UI **Configuration tab**,
not via EDID auto-detect. iPXE has no EDID parsing; the daemon writes
a resolution-tagged script per boot and serves the matching PNG.
- iVentoy docs explicitly state both Free and Pro editions **do not
support** modifying the boot background/title — it's baked into the
shipped PNG assets.
- Chrome is iPXE's native `menu` / `item` / `choose` widgets (single
highlight bar, no borders) painted on top of the PNG, with margins
set via `console --left/--right/--top/--bottom` to keep the text off
the logo. Not GRUB, not syslinux — UEFI iVentoy uses iPXE's
`snponly.efi` / `pxeboot.efi`, and `--picture` does work under UEFI
GOP despite older folklore.
Do not conflate this with Ventoy-USB, which is a separate codebase and
uses GRUB2 themes (`theme.txt`, `background_ventoy.png`, `select_c.png`).
## Rust ingredients to replicate / surpass
Most of these already exist in the workspace.
1. **Compositor (extend, don't replace)** — extend
`crates/iso-store/src/pxe_logo.rs` to emit per-resolution PNGs
(1024x768, 1280x1024, 1920x1080 as the v1 set). `image` +
`imageproc` crates handle scaling; `ab_glyph` / `fontdue` for raster
text (subtitle, hostname, version). One source SVG/logo, three to
five rendered PNGs cached on disk.
2. **Script generator**`ipxe_script.rs` already emits
`console --picture … || console`. Add a `?res=` query param (or
per-MAC client hint persisted in `hosts.json`) and serve the matching
PNG plus matching `console --x --y` line. Keep the text-console
fallback already in place.
3. **Resolution selection** — iPXE exposes `${vesa-x}` / `${vesa-y}` on
BIOS; UEFI side we can probe firmware vars at chain-time. The simpler
v1 is a "low-res / hi-res" toggle in Settings plus a per-host
override — mirrors iVentoy's UX, no kernel helper needed. True EDID
parsing is overkill for the first cut.
4. **Chrome upgrades over iVentoy** — iPXE menus are limited (single
highlight, no borders). To look distinctly cooler without leaving
iPXE: paint border / title / footer **into the PNG**, leave a window
in the middle, then `console --left/--right/--top/--bottom` to inset
the iPXE menu exactly into that window. ASCII box-drawing inside the
menu remains fragile (iPXE mangles non-ASCII on some builds — already
noted in `ipxe_script.rs`).
## Recommended architecture for the next OpenPXE release
- Build a `pxe_theme` module beside `pxe_logo.rs`: takes operator logo
+ theme tokens (accent colour, title, footer) and renders a layered
PNG (background gradient → framing chrome → logo → title bar → footer
with `${hostname}` / `${version}` / `${ip}`) at the three target
resolutions. Cache by hash of inputs.
- Serve at `/branding/pxe-menu-{w}x{h}.png`. Default 1024x768; expose a
Settings dropdown.
- In `ipxe_script.rs`, emit
`console --picture …/pxe-menu-1024x768.png --left 80 --right 80 --top 180 --bottom 60 || console`,
then the existing `menu` / `item` / `choose` block — text now lands
inside the framed window.
- Compile iPXE with `CONSOLE_FRAMEBUFFER`, `IMAGE_PNG`, `CONSOLE_CMD`,
`CONSOLE_VESAFB` (BIOS) and `CONSOLE_EFIFB` (UEFI). The v0.4.61 image
attempted this in-Docker via QEMU emulation and hit `cc1` segfaults.
The follow-up will use a Gitea Actions runner pinned to native
`linux/amd64` (an Unraid host already exists for this).
- Stretch goal: a second "theme pack" that ships a layered PNG with
subtle scanlines / grid — iPXE can't animate, but a well-designed
static composite beats iVentoy's plain centered logo handily.
## Source URLs
- https://github.com/ventoy/PXE
- https://github.com/ventoy/PXE/tree/master/iPXE
- https://github.com/ventoy/PXE/issues/11 — 1024x768 default
- https://github.com/ventoy/PXE/issues/59 — iVentoy iPXE EFI loader
- https://ipxe.org/cmd/console — `--picture` and compile flags
- https://github.com/ipxe/ipxe/discussions/945 — background image how-to
- https://github.com/ipxe/ipxe/discussions/802 — `CONSOLE_FRAMEBUFFER`
requirement
- https://github.com/ipxe/ipxe/discussions/1006 — picture resolution
behaviour
- https://www.iventoy.com/en/doc_edition.html — background / title not
user-customisable
- https://kingtam.win/archives/iventoy.html — third-party iPXE-based
iVentoy alternative
@@ -1,199 +0,0 @@
# OpenPXE v0.5.1 — SAML SSO wiring + Settings/Storage UI consolidation
**Date:** 2026-05-31
**Author:** Miles Ward (with Claude)
**Status:** Approved design → implementation
## Summary
Three workstreams for v0.5.1:
1. **Wire SAML 2.0 SSO** end-to-end (currently config is persisted but no runtime
sign-in exists). Pure-Rust implementation that preserves the static-musl /
no-OpenSSL architecture, mirroring how FleetDM exposes and handles SAML.
2. **Fold the Advanced sidebar tab into Settings** as a collapsible section.
3. **Merge the Storage tab's SMB and NFS cards** into one "Remote shares" card
with a protocol dropdown.
Then bump `0.5.0 → 0.5.1`, build the static musl image, push `:0.5.1` + `:latest`
to Gitea, create the release, and scrub registry credentials.
## Decisions (locked with the user)
- **Crypto:** pure-Rust via `bergshamra` (XML-DSig + exclusive c14n, RustCrypto-based,
`#![forbid(unsafe_code)]`, ~99% xmlsec interop). `samael` is rejected — it
hard-requires OpenSSL/`xmlsec`/`libxml2` C deps, which would break the static
musl binary and the project's pure-Rust / no-OpenSSL architecture.
- **Access model:** any SAML assertion the IdP successfully authenticates and that
we cryptographically verify mints a full operator session. No user table, no
roles, no domain allowlist. The local admin account remains a guaranteed
fallback owner regardless of SSO state.
- **Flows:** SP-initiated (the "Sign in with <IdP>" button) is always on.
IdP-initiated is supported but gated behind an `allow_idp_initiated` toggle
(default off), mirroring FleetDM's "Allow SSO login initiated by identity
provider."
## Scope boundaries (v0.5.1)
In scope: SP-initiated + (gated) IdP-initiated login, signature verification on the
SAML Response/Assertion, full SP-side semantic validation, SP metadata endpoint,
login-page button wiring.
Out of scope (note for later releases): EncryptedAssertion (assertions must be
unencrypted), signed AuthnRequests (sent unsigned; Keycloak "client signature
required" must be off), Single Logout (SLO), multi-user accounts / RBAC / JIT role
mapping.
---
## Workstream 1 — SAML SP wiring (pure-Rust)
### New dependencies (workspace)
- `bergshamra` — XML-DSig verification + exclusive c14n (pure Rust).
- `roxmltree` (read/navigate) and/or `quick-xml` (build/serialize) — parse IdP
metadata + SAMLResponse, build AuthnRequest and SP metadata.
- `x509-parser` — extract the IdP signing certificate / public key from metadata.
- `flate2` — raw DEFLATE for the HTTP-Redirect binding.
- `base64` — encode/decode SAMLRequest/SAMLResponse.
All pure-Rust → the `x86_64-unknown-linux-musl` static build stays OpenSSL-free.
Exact `bergshamra` function signatures (`verify`, `DsigContext`, `KeysManager`,
`Key`, `VerifiedReference`, `VerifyResult`) will be pinned against the installed
crate source during implementation.
### Module boundaries
Pure protocol logic lives in `openpxe-core` (no axum dependency, unit-testable);
HTTP wiring lives in `openpxe-http-api`.
- `crates/core/src/saml/mod.rs` — public surface + shared types
(`VerifiedPrincipal { email, display_name, name_id, session_index }`, `SamlError`).
- `crates/core/src/saml/metadata.rs` — parse IdP `EntityDescriptor`: IdP EntityID,
`SingleSignOnService` locations + bindings, and one or more X.509 signing
certificates. Also build **our** SP metadata XML.
- `crates/core/src/saml/authn_request.rs` — build an AuthnRequest, return both the
request ID (to track) and the encoded HTTP-Redirect query value
(deflate → base64 → URL-encode).
- `crates/core/src/saml/response.rs` — decode `SAMLResponse` (base64 → XML),
**verify the signature via bergshamra** against the IdP cert, then enforce SP
semantics, returning `VerifiedPrincipal` or a typed `SamlError`.
### SP-side validation (response.rs)
After a cryptographically valid signature over the Response and/or the Assertion:
1. `Status` is `Success`.
2. `Destination` (if present) equals our ACS URL.
3. `Conditions/AudienceRestriction/Audience` equals our SP EntityID.
4. `NotBefore` / `NotOnOrAfter` within bounds (allow small clock skew, e.g. ±60s).
5. `InResponseTo` matches an outstanding request we issued (SP-initiated). Absent
for IdP-initiated, which is only accepted when `allow_idp_initiated` is true.
6. Assertion-ID replay guard: reject a previously consumed assertion ID.
7. NameID is the email (`nameid-format:emailAddress`). Display name read from
common attributes (`name`, `displayname`, `cn`, `urn:oid:2.5.4.3`).
XML Signature Wrapping (XSW) defenses come from bergshamra (duplicate-ID rejection,
strict positional verification); enable its strict verification options. We
additionally confirm the verified `Reference` covers the element we read claims from.
### State (in `openpxe-http-api`)
Two small TTL-pruned in-memory stores (parking_lot `Mutex<HashMap<...>>`):
- **Outstanding requests:** `request_id → issued_at`, TTL ≈ 5 min, for `InResponseTo`.
- **Consumed assertions:** `assertion_id → expires_at`, TTL = assertion validity,
for replay protection.
(In-memory is acceptable: a single-container app; a restart simply invalidates
in-flight logins.)
### Routes (all pre-auth; added to the public allowlist in the auth middleware)
- `GET /api/sso/login` → build AuthnRequest, record its ID, 302 to the IdP SSO URL
(HTTP-Redirect binding) with `SAMLRequest` + `RelayState`.
- `POST /api/sso/acs` → consume `SAMLResponse` (form-encoded). Verify + validate.
On success: `SessionStore::create(email)`, set the `openpxe_session` cookie
(same attributes as forms login), 302 to the dashboard. On failure: 302 back to
the login page with an error indicator. (Mirrors FleetDM's `/sso/callback`.)
- `GET /api/sso/metadata` → serve our SP `EntityDescriptor` XML for IdP import.
### Config changes (`crates/core/src/sso.rs`)
Add to `SsoConfig` (preserve existing fields + validation):
- `entity_id: String` — SP Entity ID (mirrors FleetDM's "Entity ID"); defaults to
the configured public base URL. The ACS URL is derived as
`<public_base_url>/api/sso/acs`.
- `allow_idp_initiated: bool` — default `false`.
`GET /api/sso` returns the new fields; `PUT /api/sso` validates and persists them.
### Login page (`crates/webui/src/app.js`)
Replace the "configured · runtime pending" message: the existing
"Sign in with <IdP>" button navigates to `GET /api/sso/login`. Render the IdP logo
(if `idp_logo_url` set) and use `idp_name` as the label. Keep the existing
FleetDM-style login layout.
### Testing
- `core/saml` unit tests using a self-signed test keypair we control:
- Parse representative Keycloak IdP metadata → correct SSO URL + cert.
- Build an AuthnRequest → well-formed, deflate/base64 round-trips, ID recorded.
- A correctly signed Response → `VerifiedPrincipal { email, .. }`.
- Reject: tampered signature, expired (`NotOnOrAfter`), wrong audience,
replayed assertion ID, unsigned response, `Status != Success`.
- `http-api` integration test: `GET /api/sso/login` returns a 302 with a
`SAMLRequest` query param; a crafted signed `SAMLResponse` POSTed to
`/api/sso/acs` (signed with the test key) sets an `openpxe_session` cookie.
---
## Workstream 2 — Advanced tab → Settings
- Remove the `Advanced` sidebar entry (`crates/webui/src/index.html`) and its
`advanced` view route in `app.js`.
- In the Settings view, append a **collapsible "Advanced" disclosure**
(default-collapsed) at the bottom containing the existing **Webhook
Notifications** card and the **API reference** block (moved out of the removed
Advanced view).
- No backend changes; `/api/notify*` and `/api/docs` endpoints are unchanged.
---
## Workstream 3 — Storage: merge SMB + NFS → "Remote shares"
- Replace the separate "SMB shares" and "NFS shares" cards with a single
**"Remote shares"** card:
- One add-form with a **protocol dropdown (SMB / NFS)**. Selecting the protocol
swaps the fields: SMB → server, share, guest checkbox, username, password;
NFS → server, export path.
- One unified table with a leading **Protocol** column (SMB/NFS badge), then
server/share-or-export, auth, ISO count, reachability, and Re-scan / Remove
actions.
- **No backend changes.** The form dispatches to the existing
`POST /api/smb-shares` or `POST /api/nfs-shares`; the table merges
`GET /api/smb-shares` + `GET /api/nfs-shares`, tagging each row with its
protocol. Re-scan/Remove call the existing per-protocol endpoints.
- Leaves the card pattern open for a future "Config files" card.
---
## Release
1. Bump workspace version `0.5.0 → 0.5.1` (`Cargo.toml`).
2. `cargo fmt`, `cargo clippy`, `cargo test` (all crates) green.
3. Build the static musl binary + Docker image; verify SAML deps compile clean
under musl (no OpenSSL/C linkage).
4. Push `openpxe:0.5.1` + `openpxe:latest` to Gitea via the established
temp-DOCKER_CONFIG pipeline; scrub credentials (logout + verify no token traces).
5. Create the Gitea release `v0.5.1` with notes.
## Risks
- `bergshamra` is pre-1.0 and unaudited. Mitigation: pin the version, enable strict
verification, keep the local-admin fallback, and own the SP-semantic checks
carefully (audience/Conditions/replay/InResponseTo — where SP vulns usually live).
- SAML is security-sensitive; negative tests (tamper/expiry/audience/replay/unsigned)
are part of the definition of done, not optional.
+6 -6
View File
@@ -115,7 +115,7 @@ Two options. Pick one.
Big ISOs stream — there is no 2 GB limit, but expect upload to be Big ISOs stream — there is no 2 GB limit, but expect upload to be
throttled by your browser ↔ host link. The UI shows a progress bar; the throttled by your browser ↔ host link. The UI shows a progress bar; the
animated OpenPXE mark on the Dashboard tab fires up while imaging is in animated anvil on the Dashboard tab fires up while imaging is in
flight. flight.
### 2b. Bulk seed from a directory (recommended for fresh deploys / CI) ### 2b. Bulk seed from a directory (recommended for fresh deploys / CI)
@@ -290,7 +290,7 @@ INFO openpxe::http: GET /iso/ubuntu-…/casper/initrd Range=bytes=0- 200 OK 75
``` ```
The **Terminal** tab in the web UI shows the same thing live, plus a The **Terminal** tab in the web UI shows the same thing live, plus a
short whitelisted command palette (`status`, `clients`, `queue`, short whitelisted command palette (`status`, `clients`, `gate`,
`hosts`, `log`). `hosts`, `log`).
### 5d. Internet-side ISO sources ### 5d. Internet-side ISO sources
@@ -347,19 +347,19 @@ default for production hardware.
## 7. Re-imaging — the “Queued Deployment” flow ## 7. Re-imaging — the “Queued Deployment” flow
Different scenario: you have **a rack of 30 servers** to image Different scenario: you have **a rack of 30 servers** to image
identically, all at once. Dont bind 30 MACs by hand. Use the queue. identically, all at once. Dont bind 30 MACs by hand. Use the gate.
1. **Dont** create host bindings. 1. **Dont** create host bindings.
2. PXE-boot every machine. They land on the menu. 2. PXE-boot every machine. They land on the menu.
3. On each: select **Queued Deployment**. They get position #1, #2, 3. On each: select **Queued Deployment**. They get position #1, #2,
…, #30 and start long-polling. …, #30 and start long-polling.
4. In the UI: **Queue** tab shows all 30 lined up. Pick the 4. In the UI: **Forge Gate** tab shows all 30 lined up. Pick the
ISO, click **Assign to all waiting**. ISO, click **Assign to all waiting**.
5. Every clients open long-poll wakes up at the same instant and 5. Every clients open long-poll wakes up at the same instant and
chains the same boot script. They all start imaging chains the same boot script. They all start imaging
simultaneously. simultaneously — the “horse race gate” opens.
The animated OpenPXE progress widget on the Dashboard runs while any client is The animated anvil widget on the Dashboard runs while any client is
still in the kernel-fetch phase. still in the kernel-fetch phase.
--- ---
-90
View File
@@ -1,90 +0,0 @@
#!/usr/bin/env bash
# Build PNG-enabled iPXE binaries from source.
#
# Why from source: the official boot.ipxe.org binaries (and the
# Debian-packaged ones) are NOT built with CONSOLE_FRAMEBUFFER +
# IMAGE_PNG + CONSOLE_CMD, so `console --picture` is a no-op on them —
# you can't paint a graphical boot-menu background. iVentoy solves this
# by shipping its own iPXE build with exactly those three flags; we do
# the same, from upstream iPXE, with a thin auditable config delta
# (deploy/ipxe/local/{general,console}.h).
#
# Why a real cross-compiler instead of QEMU: building amd64 iPXE by
# emulating an amd64 gcc under QEMU on an arm64 host intermittently
# segfaults cc1 (the reason this was stuck for ~8 releases). Running a
# NATIVE arm64 gcc that cross-targets x86_64 (CROSS_COMPILE=
# x86_64-linux-gnu-) sidesteps emulation entirely — the compiler is a
# native binary, it just emits x86_64 objects. This stage is meant to
# run on $BUILDPLATFORM (the native builder arch), NOT the emulated
# target platform.
#
# Outputs (into $DEST), using the filenames OpenPXE's arch mapping
# expects:
# snponly.efi x86_64 UEFI, PNG-enabled
# ipxe.efi x86_64 UEFI, PNG-enabled (bundled drivers)
#
# We build ONLY x86_64 UEFI, always via the x86_64 cross toolchain
# (`x86_64-linux-gnu-gcc`). That's deliberately host-arch-agnostic: it
# works whether this stage runs on an arm64 Mac builder or an amd64 CI
# runner, because the cross compiler runs native and emits x86_64
# either way. Building arm64-efi or BIOS here would re-introduce a
# dependency on the host arch (native arm64 build) or a 32-bit multilib
# toolchain — so those arches keep their upstream-fetched (no-PNG)
# binaries and fall back to the menu's clean `|| console` text screen.
# Modern PXE clients are overwhelmingly x86_64 UEFI, which get the full
# graphical background.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DEST="${1:-$ROOT/assets/ipxe}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
# Pinned upstream iPXE. Rolling master is fine functionally, but a pin keeps
# builds reproducible, protects against a transient master breakage, and —
# crucially for the Docker image — busting this value invalidates the cached
# ipxe-build layer so an "update iPXE" release actually recompiles from the
# new upstream. Bump deliberately to a recent master commit.
#
# v0.6.1: ipxe/ipxe master @ 2026-06-09 (newer NIC drivers + EFI fixes;
# mirrors iVentoy 1.0.35 "Update iPXE").
IPXE_REPO="https://github.com/ipxe/ipxe.git"
IPXE_REF="${IPXE_REF:-95ffbf4745553e8a207922389929e1943c0237c0}"
echo ">> fetching iPXE ($IPXE_REF)"
# Shallow-fetch the exact ref: works for a full commit SHA (GitHub allows
# reachable-SHA1-in-want) and for branch/tag names. Fall back to a full
# clone + checkout if the server refuses a direct fetch of this ref.
git init -q "$WORK/ipxe"
git -C "$WORK/ipxe" remote add origin "$IPXE_REPO"
if git -C "$WORK/ipxe" fetch -q --depth 1 origin "$IPXE_REF"; then
git -C "$WORK/ipxe" checkout -q FETCH_HEAD
else
echo " direct fetch failed; falling back to full clone + checkout"
rm -rf "$WORK/ipxe"
git clone -q "$IPXE_REPO" "$WORK/ipxe"
git -C "$WORK/ipxe" checkout -q "$IPXE_REF"
fi
SRC="$WORK/ipxe/src"
echo ">> applying OpenPXE config overrides (PNG + framebuffer + console cmd)"
mkdir -p "$SRC/config/local"
cp "$ROOT/deploy/ipxe/local/general.h" "$SRC/config/local/general.h"
cp "$ROOT/deploy/ipxe/local/console.h" "$SRC/config/local/console.h"
mkdir -p "$DEST"
# x86_64 UEFI — cross-compiled with the native arm64 gcc targeting
# x86_64. HOST_CC stays the native cc for iPXE's build-time utilities
# (elf2efi, zbin, …); only the target objects use the cross compiler.
echo ">> building x86_64 UEFI (snponly.efi, ipxe.efi)"
make -C "$SRC" -j"$(nproc)" \
CROSS_COMPILE=x86_64-linux-gnu- \
bin-x86_64-efi/snponly.efi \
bin-x86_64-efi/ipxe.efi
cp "$SRC/bin-x86_64-efi/snponly.efi" "$DEST/snponly.efi"
cp "$SRC/bin-x86_64-efi/ipxe.efi" "$DEST/ipxe.efi"
echo ">> iPXE build complete:"
ls -l "$DEST"/snponly.efi "$DEST"/ipxe.efi
+1 -9
View File
@@ -29,19 +29,11 @@ mkdir -p "$DEST"
# Upstream uses arch-scoped subdirectories; we flatten to the names our # Upstream uses arch-scoped subdirectories; we flatten to the names our
# ClientArch::ipxe_bootfile() expects. # ClientArch::ipxe_bootfile() expects.
declare -a MAP=( declare -a MAP=(
# DriverMode::Firmware (default) — reuse the firmware UNDI/SNP NIC stack.
"undionly.kpxe=undionly.kpxe" "undionly.kpxe=undionly.kpxe"
"snponly.efi=x86_64-efi/snponly.efi" "snponly.efi=x86_64-efi/snponly.efi"
"snponly-i386.efi=i386-efi/snponly.efi" "snponly-i386.efi=i386-efi/snponly.efi"
"snponly-arm64.efi=arm64-efi/snponly.efi" "snponly-arm64.efi=arm64-efi/snponly.efi"
# DriverMode::Builtin (v0.6.1 automatic fallback) — iPXE's own all-drivers "ipxe.efi=x86_64-efi/ipxe.efi" # fallback with bundled drivers
# builds, advertised by the DHCP proxy to a MAC whose firmware NIC stack
# failed to chainload. (x86_64 ipxe.efi is rebuilt from source with PNG in
# build-ipxe.sh and overlaid on top of this fetched baseline.)
"ipxe.efi=x86_64-efi/ipxe.efi"
"ipxe.pxe=ipxe.pxe"
"ipxe-i386.efi=i386-efi/ipxe.efi"
"ipxe-arm64.efi=arm64-efi/ipxe.efi"
) )
BASE="https://boot.ipxe.org" BASE="https://boot.ipxe.org"
-96
View File
@@ -1,96 +0,0 @@
#!/usr/bin/env bash
# Fetch Fedora's Microsoft-signed Secure Boot chain — shim + GRUB — and
# place the EFI binaries under assets/ipxe/ with the filenames OpenPXE's
# DriverMode::Shim mapping expects:
#
# shimx64.efi x86_64: Microsoft-signed shim (first stage)
# grubx64.efi x86_64: Fedora-signed GRUB (loaded by shim, fetches
# the server-rendered grub.cfg over TFTP/HTTP)
# shimaa64.efi arm64 equivalents (best-effort — see below)
# grubaa64.efi
#
# Why Fedora: a supply-chain decision made deliberately (v0.7.0) — one
# vendor, fast security turnaround, and the same chain most netboot
# projects redistribute. The binaries are extracted from the official
# distro RPMs and shipped BYTE-FOR-BYTE UNMODIFIED; their signatures are
# what make the chain work, and modifying them would break it. This is
# the standard documented netboot path for Secure Boot (Red Hat
# Satellite, SUSE HTTPBoot) and involves no test certificates and no
# client trust-store changes.
#
# Trust model matches fetch-ipxe.sh: HTTPS to the official distribution
# point, no sha pinning because we track the latest signed build (which
# rotates on SBAT revocations — pinning would mean shipping revoked
# shims). Mirror to your own artifact store for deterministic builds.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DEST="${1:-$ROOT/assets/ipxe}"
mkdir -p "$DEST"
FEDORA_RELEASE="${FEDORA_RELEASE:-43}"
BASE="${FEDORA_MIRROR:-https://dl.fedoraproject.org/pub/fedora/linux/releases/$FEDORA_RELEASE/Everything}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
# Find the newest RPM in a repo directory whose name starts with
# `$pattern` followed by a version digit (anchoring on the digit keeps
# `grub2-efi-x64` from matching `grub2-efi-x64-cdboot`).
latest_rpm() {
local dir_url="$1" pattern="$2"
curl -fsSL "$dir_url/" \
| grep -oE "href=\"${pattern}-[0-9][^\"]*\.rpm\"" \
| sed 's/^href="//; s/"$//' \
| sort -V | tail -1
}
# fetch_chain <repo-arch> <shim-pkg> <grub-pkg> <shim-out> <grub-out> <hard|soft>
fetch_chain() {
local arch="$1" shim_pkg="$2" grub_pkg="$3" shim_out="$4" grub_out="$5" mode="$6"
local pkg_base="$BASE/$arch/os/Packages"
local sdir="$pkg_base/${shim_pkg:0:1}" gdir="$pkg_base/${grub_pkg:0:1}"
local shim_rpm grub_rpm
shim_rpm="$(latest_rpm "$sdir" "$shim_pkg" || true)"
grub_rpm="$(latest_rpm "$gdir" "$grub_pkg" || true)"
if [ -z "$shim_rpm" ] || [ -z "$grub_rpm" ]; then
echo "!! could not locate $shim_pkg/$grub_pkg RPMs under $pkg_base"
[ "$mode" = "hard" ] && exit 2
echo " skipping $arch Secure Boot chain (best-effort)"
return 0
fi
echo ">> $arch: $shim_rpm + $grub_rpm"
local exdir="$WORK/$arch"
mkdir -p "$exdir"
curl -fsSL -o "$exdir/shim.rpm" "$sdir/$shim_rpm"
curl -fsSL -o "$exdir/grub.rpm" "$gdir/$grub_rpm"
( cd "$exdir" \
&& rpm2cpio shim.rpm | cpio -idm --quiet "./boot/efi/EFI/*/$shim_out" \
&& rpm2cpio grub.rpm | cpio -idm --quiet "./boot/efi/EFI/*/$grub_out" )
local shim_path grub_path
shim_path="$(find "$exdir/boot" -name "$shim_out" | head -1)"
grub_path="$(find "$exdir/boot" -name "$grub_out" | head -1)"
if [ -z "$shim_path" ] || [ -z "$grub_path" ]; then
echo "!! RPM layout changed — $shim_out/$grub_out not found inside the packages"
[ "$mode" = "hard" ] && exit 2
return 0
fi
cp "$shim_path" "$DEST/$shim_out"
cp "$grub_path" "$DEST/$grub_out"
echo " installed $shim_out + $grub_out"
}
# x86_64 is the headline Secure Boot audience — fail the build if it
# can't be assembled so a regression is loud, not silent.
fetch_chain x86_64 shim-x64 grub2-efi-x64 shimx64.efi grubx64.efi hard
# arm64 is best-effort: skipping just means no Shim escalation rung for
# that arch (logged at startup by ipxe-assets::log_availability).
fetch_chain aarch64 shim-aa64 grub2-efi-aa64 shimaa64.efi grubaa64.efi soft
echo
echo "Secure Boot chain assets now in $DEST:"
ls -lh "$DEST"/shim*.efi "$DEST"/grub*.efi 2>/dev/null || true