Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a71057fce6 | ||
|
|
29040e8a5a | ||
|
|
3a32d65fb7 | ||
|
|
7f25bb681c | ||
|
|
5da05a519d | ||
|
|
4f193cac05 | ||
|
|
24879fcc90 | ||
|
|
5df0fd5972 | ||
|
|
06695c3d77 | ||
|
|
cb51b8db75 | ||
|
|
d6a9df85d7 | ||
|
|
9fc9a9a1af | ||
|
|
ac433b30e9 | ||
|
|
c0d17fa9ca | ||
|
|
edf3a69daa | ||
|
|
44a2212abe | ||
|
|
674a69f93b | ||
|
|
7358013093 | ||
|
|
a906c47f53 |
@@ -11,3 +11,6 @@ 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
+1668
-495
File diff suppressed because it is too large
Load Diff
+42
-13
@@ -12,7 +12,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.5.2"
|
version = "0.7.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
@@ -20,41 +20,45 @@ repository = "https://gitea.milesward.dev/mward4/OpenPXE"
|
|||||||
authors = ["OpenPXE contributors"]
|
authors = ["OpenPXE contributors"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
tokio = { version = "1.40", features = ["full"] }
|
tokio = { version = "1.52", 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"
|
||||||
|
|
||||||
dhcproto = "0.12"
|
# v0.6.2: dhcproto 0.15 drops the deprecated trust-dns-proto dependency
|
||||||
socket2 = { version = "0.5", features = ["all"] }
|
# (replaced by hickory-proto) and carries three releases of DHCP option
|
||||||
|
# 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.7", features = ["macros", "multipart", "http2"] }
|
axum = { version = "0.8", 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.4"
|
hyper = "1.9"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||||
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 = "1.0"
|
thiserror = "2.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.15"
|
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"] }
|
||||||
|
|
||||||
@@ -76,7 +80,7 @@ lettre = { version = "0.11", default-features = false, features = ["smtp-transpo
|
|||||||
# C deps), so the static musl binary stays OpenSSL-free — samael was
|
# C deps), so the static musl binary stays OpenSSL-free — samael was
|
||||||
# rejected precisely because it hard-requires OpenSSL. We build the thin
|
# rejected precisely because it hard-requires OpenSSL. We build the thin
|
||||||
# SP layer (AuthnRequest, metadata parse, SAMLResponse semantics) on top.
|
# SP layer (AuthnRequest, metadata parse, SAMLResponse semantics) on top.
|
||||||
bergshamra = "0.4"
|
bergshamra = "0.5"
|
||||||
roxmltree = "0.21"
|
roxmltree = "0.21"
|
||||||
quick-xml = "0.40"
|
quick-xml = "0.40"
|
||||||
x509-parser = "0.18"
|
x509-parser = "0.18"
|
||||||
@@ -85,6 +89,31 @@ x509-parser = "0.18"
|
|||||||
flate2 = "1.1"
|
flate2 = "1.1"
|
||||||
base64 = "0.22"
|
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" }
|
||||||
|
|||||||
@@ -1,301 +1,270 @@
|
|||||||
# OpenPXE
|
<p align="center">
|
||||||
|
<img src="docs/openpxe-logo.svg" alt="OpenPXE" width="104" height="104" />
|
||||||
|
</p>
|
||||||
|
|
||||||
Container-native PXE boot server. A Rust reimplementation of
|
<h1 align="center">OpenPXE</h1>
|
||||||
[iVentoy (ventoy/PXE)](https://github.com/ventoy/PXE), designed from scratch
|
|
||||||
for Docker/OCI and OpenShift. Upload `.iso` files via the web UI; network
|
|
||||||
clients PXE-boot them.
|
|
||||||
|
|
||||||
> **Status:** v0.4.1 / pre-beta. Phases 1–5 complete: full PXE stack,
|
<p align="center">
|
||||||
> Queued Deployment queue, NFS-share ISO sources, live tracing log + an
|
<strong>Container-native network boot & OS deployment — built in Rust.</strong>
|
||||||
> operator terminal, per-MAC host bindings, Prometheus `/metrics`,
|
</p>
|
||||||
> light/dark theme toggle, animated OpenPXE imaging-progress widget,
|
|
||||||
> chunked ISO uploads, and per-ISO boot passwords. The test suite and
|
|
||||||
> clippy are part of the release checklist. Ready for real-hardware validation.
|
|
||||||
|
|
||||||
## Design non-negotiables
|
<p align="center">
|
||||||
|
Drag in an ISO. PXE-boot and image an entire fleet from a browser.<br/>
|
||||||
|
No iPXE scripting. No <code>dnsmasq</code> + <code>tftpd</code> + Samba glue. No glibc. No garbage collector.
|
||||||
|
</p>
|
||||||
|
|
||||||
1. **Fully offline / air-gap deployable.** Zero CDN assets. Zero external
|
<p align="center">
|
||||||
HTTP calls from the server, the browser, or the generated iPXE scripts.
|
<img alt="release" src="https://img.shields.io/badge/release-v0.6.0-2874d7" />
|
||||||
Build the container once, run forever disconnected.
|
<img alt="license" src="https://img.shields.io/badge/license-MIT%20%7C%20Apache--2.0-59824f" />
|
||||||
2. **iPXE is a backend implementation detail.** No `.ipxe` upload path, no
|
<img alt="rust" src="https://img.shields.io/badge/built%20with-Rust-fb8841?logo=rust&logoColor=white" />
|
||||||
manual script editing, no iPXE terminology in the UI. Every knob in the
|
<img alt="container" src="https://img.shields.io/badge/container--native-OCI%20%C2%B7%20OpenShift-2496ED?logo=docker&logoColor=white" />
|
||||||
web UI maps to a specific script-generation behavior inside the binary.
|
<img alt="binary" src="https://img.shields.io/badge/static-musl%20%C2%B7%20~18MB-330f1f" />
|
||||||
3. **The client trust store is off-limits.** No test-signed drivers, no
|
</p>
|
||||||
`bcdedit /set testsigning on`, no certificates injected into WinPE or
|
|
||||||
the target OS.
|
|
||||||
|
|
||||||
## What it does
|
---
|
||||||
|
|
||||||
1. **DHCP proxy** (RFC 4578). Coexists with your existing DHCP server —
|
OpenPXE turns bare-metal provisioning into a single container with a web UI. It's a
|
||||||
never assigns IPs. Listens on UDP 67 + UDP 4011.
|
ground-up Rust reimplementation of [iVentoy (ventoy/PXE)](https://github.com/ventoy/PXE),
|
||||||
2. **TFTP server** (RFC 1350 + RFC 2347/2348/2349/7440 option negotiation)
|
designed for Docker/OCI and OpenShift instead of a Windows desktop — so it drops onto
|
||||||
that serves architecture-specific iPXE binaries to firmware PXE ROMs.
|
an Unraid box, a Linux server, or a Kubernetes cluster and just runs.
|
||||||
3. **HTTP server** that serves the web UI, the generated iPXE boot scripts,
|
|
||||||
raw ISOs (with Range), and files inside ISOs without prior extraction.
|
|
||||||
4. **ISO introspection**: auto-detects the distro family and generates the
|
|
||||||
appropriate kernel+initrd or wimboot chain. No manual config.
|
|
||||||
5. **Hierarchical PXE menu** mirroring the Phase 2 spec:
|
|
||||||
```
|
|
||||||
Default > Boot from Local HDD
|
|
||||||
Installers > Linux Installers / Windows Installers
|
|
||||||
Tools > Utilities / OpenPXE Shell / Network Card Info
|
|
||||||
Queued Deployment
|
|
||||||
```
|
|
||||||
6. **Queued Deployment queue** — the coordinated launch flow. A client that
|
|
||||||
selects *Queued Deployment* gets a numbered position and waits. The
|
|
||||||
operator picks an ISO in the web UI and fires it to every waiting
|
|
||||||
client simultaneously.
|
|
||||||
7. **Web UI** (Netbox-style): sidebar nav (Dashboard / Network / Queue /
|
|
||||||
Storage / Hosts / Terminal / About), light + dark themes
|
|
||||||
(toggle top-right or press `T`), animated OpenPXE progress
|
|
||||||
widget when devices are imaging. All assets served from the binary —
|
|
||||||
no external requests.
|
|
||||||
8. **Per-MAC host bindings.** Pin a MAC to a boot target and the client
|
|
||||||
skips the menu, chains straight through.
|
|
||||||
9. **Prometheus metrics** at `/metrics` — DHCP replies by arch, TFTP
|
|
||||||
transfer counts and bytes, HTTP request counts by route, queue /
|
|
||||||
imaging gauges, uptime, build info. Plain text exposition format,
|
|
||||||
no external metrics framework dependency.
|
|
||||||
8. **Settings API** lets you change the default boot-menu timeout (default
|
|
||||||
600s), the timeout action (stay / Local HDD / Queued Deployment), and
|
|
||||||
feature toggles like Windows ISO support. The iPXE scripts regenerate
|
|
||||||
on every request using current settings.
|
|
||||||
|
|
||||||
### Architectures supported on day one
|
Upload `.iso` files (or point at a remote share), and any machine on the network boots
|
||||||
|
them — Linux installers, live tools, or stock Windows setup — with **zero iPXE knowledge
|
||||||
|
required by the operator.**
|
||||||
|
|
||||||
| DHCP option 93 | Architecture | Binary served |
|
> **Status — v0.6.0, late pre-beta.** The full PXE stack, web UI, remote ISO libraries
|
||||||
|----------------|-----------------|-------------------------|
|
> (SMB/NFS/SFTP), Windows deployment, queued fleet rollout, SAML SSO, and Prometheus
|
||||||
| `0x0000` | Legacy x86 BIOS | `undionly.kpxe` |
|
> metrics are implemented and test-covered. The release checklist gates every tag on the
|
||||||
| `0x0006` | IA32 UEFI | `snponly-i386.efi` |
|
> full test suite + `clippy`. Currently in real-hardware validation.
|
||||||
| `0x0007`/`0x0009` | x86_64 UEFI | `snponly.efi` |
|
|
||||||
| `0x000B` | ARM64 UEFI | `snponly-arm64.efi` |
|
|
||||||
|
|
||||||
UEFI firmware that sends `HTTPClient` in option 60 is handled too — we
|
## Why OpenPXE
|
||||||
skip TFTP and respond with an HTTP URL.
|
|
||||||
|
|
||||||
## Quick start — MVP container (recommended)
|
Standing up network boot the traditional way means hand-wiring `dnsmasq`, a TFTP daemon,
|
||||||
|
hand-written iPXE menu scripts, an HTTP server, and Samba — then keeping that fragile
|
||||||
|
stack alive, and discovering none of it containerizes cleanly (kernel-mount NFS, raw
|
||||||
|
sockets, `CAP_SYS_ADMIN`). iVentoy solved the UX beautifully, but it's a Windows GUI app.
|
||||||
|
|
||||||
|
OpenPXE collapses that whole stack into **one statically-linked binary in one container**:
|
||||||
|
|
||||||
|
- **A web UI does everything.** iPXE is an internal implementation detail — there is no
|
||||||
|
script upload, no `.ipxe` editing, no PXE jargon in the interface.
|
||||||
|
- **It runs anywhere a container runs.** No kernel modules, no privileged mode — proxy-mode
|
||||||
|
DHCP + `NET_BIND_SERVICE` is the entire requirement. Verified on Unraid, plain Docker,
|
||||||
|
and OpenShift's restricted SCC.
|
||||||
|
- **It's air-gap native.** Zero CDN assets, zero outbound calls from the server, browser,
|
||||||
|
or generated boot scripts. Build the image once, run it forever, disconnected.
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
#### Boot stack
|
||||||
|
- **DHCP proxy** (RFC 4578) that coexists with your existing DHCP — it never hands out IPs.
|
||||||
|
- **TFTP** (RFC 1350 + 2347/2348/2349/7440 option negotiation) serving arch-correct iPXE firmware.
|
||||||
|
- **HTTP** serving the UI, generated boot scripts, raw ISOs (with byte-range), and files
|
||||||
|
*inside* ISOs with no prior extraction.
|
||||||
|
- **Graphical iPXE boot menu** built from your uploads, with a PNG background and a clean
|
||||||
|
hierarchy — generated fresh on every request from current settings.
|
||||||
|
|
||||||
|
#### ISO management & remote libraries
|
||||||
|
- **Drag-and-drop chunked uploads** that don't 502 on multi-GB images.
|
||||||
|
- **Automatic introspection** — detects the distro family and generates the right
|
||||||
|
kernel+initrd or Windows `wimboot` chain. No manual config.
|
||||||
|
- **Remote ISO libraries, streamed on demand** (no local cache) over **SMB, NFS, or SFTP** —
|
||||||
|
see the table below.
|
||||||
|
|
||||||
|
#### Fleet deployment
|
||||||
|
- **Queued Deployment** — clients join a queue and wait; the operator fires one image at
|
||||||
|
every waiting machine simultaneously.
|
||||||
|
- **Per-MAC host bindings** — pin a MAC straight to a target (with optional auto hostname,
|
||||||
|
auto IP, and an unattended answer file); it skips the menu and chains through.
|
||||||
|
- **Unattended installs** — upload Kickstart / Preseed / Autoinstall / Windows answer files;
|
||||||
|
they're templated per-host (hostname / IP / MAC) and served only to booting clients.
|
||||||
|
- **Windows deployment** from a stock Microsoft ISO — **every binary the client runs stays
|
||||||
|
Microsoft-signed** (details below).
|
||||||
|
|
||||||
|
#### Operations & access
|
||||||
|
- **SAML 2.0 single sign-on** (pure-Rust SP, no OpenSSL/xmlsec) alongside local accounts.
|
||||||
|
- **Custom branding** — light / dark / PXE-client logos and favicon.
|
||||||
|
- **Notifications** — Slack / Teams / Discord webhooks and SMTP email on boot events.
|
||||||
|
- **Prometheus `/metrics`**, a built-in operator **terminal**, live tracing log, and
|
||||||
|
`/healthz` · `/readyz` probes.
|
||||||
|
- **Layered config** — defaults → TOML file → `OPENPXE_*` env, in that order.
|
||||||
|
|
||||||
|
## Built in Rust
|
||||||
|
|
||||||
|
Rust isn't a checkbox here — it's why OpenPXE deploys the way it does:
|
||||||
|
|
||||||
|
- **One static binary, ~18 MB.** Compiled to `x86_64-unknown-linux-musl` — no glibc, no
|
||||||
|
interpreter, no sidecar runtime. The runtime image is "binary + a few CLI tools."
|
||||||
|
- **No garbage collector, async throughout.** A Tokio runtime drives DHCP, TFTP, HTTP, and
|
||||||
|
many concurrent multi-GB ISO streams on a tiny, predictable memory footprint — it idles
|
||||||
|
near-zero and never GC-pauses mid-transfer.
|
||||||
|
- **Memory-safe by construction.** `unsafe` is **denied workspace-wide**; the only
|
||||||
|
exceptions are two small, individually-audited FFI calls (`statvfs` for disk usage and a
|
||||||
|
Samba `SIGHUP`).
|
||||||
|
- **OpenSSL-free, pure-Rust crypto.** TLS via `rustls`/`ring`; the SAML Service Provider
|
||||||
|
does XML-DSig verification with RustCrypto — no `xmlsec`, no `libxml2`, no C crypto to
|
||||||
|
CVE-patch. Even the SMB/NFS/SFTP clients avoid C libraries.
|
||||||
|
- **Sub-minute, reproducible container builds.** Cross-compiled with `cargo-zigbuild`
|
||||||
|
(zig as the linker) — a full image builds in well under a minute on a warm cache, with
|
||||||
|
no QEMU emulation.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
### Run the container
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Pull bundled iPXE binaries (~2 MB, one-time).
|
# Build the self-contained image (iPXE binaries are fetched + built inside the Dockerfile).
|
||||||
./scripts/fetch-ipxe.sh
|
docker build -f deploy/docker/Dockerfile -t openpxe:0.5.5 .
|
||||||
|
|
||||||
# 2. Build the container image (~3 min first time).
|
# Run it on the box plugged into your PXE network. Host networking is required in
|
||||||
docker buildx build -f deploy/docker/Dockerfile -t openpxe:0.4.1 --load .
|
# proxy mode so the container sees DHCPDISCOVER broadcasts; set PUBLIC_IP to this
|
||||||
|
# host's LAN address so advertised boot URLs are reachable.
|
||||||
# 3. Run it on the box plugged into your PXE network. Set PUBLIC_IP to
|
docker run -d --name openpxe --network host \
|
||||||
# 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.4.1
|
openpxe:0.5.5
|
||||||
|
|
||||||
# 4. Open the UI and drop an ISO in.
|
# Open the UI and drop an ISO in.
|
||||||
open http://10.0.0.5
|
open http://10.0.0.5
|
||||||
```
|
```
|
||||||
|
|
||||||
Host networking is required in proxy mode so the container sees DHCPDISCOVER
|
> On macOS/Windows, Docker runs inside a Linux VM, so "host network" means the VM — use
|
||||||
broadcasts from the PXE VLAN. On macOS/Windows hosts Docker runs in a Linux
|
> the `openpxe-dev` service in `docker-compose.yml` for API-only testing on a laptop:
|
||||||
VM, so "host" means the VM — use `openpxe-dev` in `docker-compose.yml` for
|
> `OPENPXE_PUBLIC_IP=127.0.0.1 docker compose up openpxe-dev`.
|
||||||
API-only testing on a laptop.
|
|
||||||
|
|
||||||
### Quick start — docker compose
|
### Build from source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# MVP / API testing on a laptop (no DHCP, high ports):
|
./scripts/fetch-ipxe.sh # populate assets/ipxe/ (embedded at compile time)
|
||||||
OPENPXE_PUBLIC_IP=127.0.0.1 docker compose up openpxe-dev
|
cargo run --release # needs root or CAP_NET_BIND_SERVICE for :80/:69
|
||||||
# Real PXE deployment on a Linux host (host network, DHCP proxy on):
|
|
||||||
OPENPXE_PUBLIC_IP=10.0.0.5 docker compose up openpxe
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Multi-arch build + push
|
### Pre-seed ISOs from a directory
|
||||||
|
|
||||||
For deploying to x86_64 servers, build both arches in one manifest:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# One-time: bootstrap a multi-arch builder.
|
|
||||||
docker buildx create --name openpxe-multi --driver docker-container --use
|
|
||||||
|
|
||||||
# Build + push both linux/amd64 and linux/arm64 under one tag.
|
|
||||||
docker buildx build --builder openpxe-multi \
|
|
||||||
--platform linux/amd64,linux/arm64 \
|
|
||||||
-t ghcr.io/YOUR-ORG/openpxe:0.4.1 \
|
|
||||||
--push \
|
|
||||||
-f deploy/docker/Dockerfile .
|
|
||||||
```
|
|
||||||
|
|
||||||
On an Apple Silicon host, the amd64 stage runs under QEMU emulation (~10-15 min for a cold cache). On a Linux x86_64 host, both arches build natively at normal speed. CI runners on GitHub Actions with `docker/build-push-action@v5` handle this cleanly.
|
|
||||||
|
|
||||||
### Build from source (no container)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/fetch-ipxe.sh
|
|
||||||
cargo run --release # needs NET_BIND_SERVICE or root for :80/:69
|
|
||||||
```
|
|
||||||
|
|
||||||
### Container health probes
|
|
||||||
|
|
||||||
| Endpoint | Purpose |
|
|
||||||
|-------------|---------------------------------------------------------------|
|
|
||||||
| `/healthz` | Liveness — HTTP stack alive. Always 200. |
|
|
||||||
| `/readyz` | Readiness — 200 only if iPXE binaries bundled + ISO dir OK. |
|
|
||||||
| `/api/status` | Full JSON status: versions, assets, counts, live settings, SMB state. |
|
|
||||||
|
|
||||||
### Pre-seeding ISOs from a directory
|
|
||||||
|
|
||||||
For CI, pre-baked homelab deployments, or a fresh PVC, the binary has a
|
|
||||||
`seed` subcommand that imports every `*.iso` from a host path through the
|
|
||||||
same pipeline the web UI uses (introspection + boot-entry generation):
|
|
||||||
|
|
||||||
```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.4.1 seed --from /seed
|
openpxe:0.5.5 seed --from /seed # add --dry-run to preview
|
||||||
|
|
||||||
# Dry run first to see what would be imported:
|
|
||||||
docker run --rm -v /my/iso-library:/seed:ro openpxe:0.4.1 seed --from /seed --dry-run
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment overrides
|
## Remote ISO libraries
|
||||||
|
|
||||||
| Var | Default | Meaning |
|
Point OpenPXE at a NAS and boot ISOs straight off it — **read on demand, no local copy**,
|
||||||
|------------------------|-----------------------------|----------------------------------------|
|
so a 50-ISO library costs zero disk on the OpenPXE host. All three clients are userspace
|
||||||
| `OPENPXE_HTTP_PORT` | `80` | Web UI + boot script HTTP port |
|
(no kernel mounts, no `CAP_SYS_ADMIN`); pick whichever your storage speaks.
|
||||||
| `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 |
|
|
||||||
|
|
||||||
## What the boot menu looks like on a real client
|
| Protocol | Implementation | Auth | HTTP Range¹ |
|
||||||
|
|----------|----------------|------|-------------|
|
||||||
|
| **NFS** (v3) | Pure-Rust in-process client | Client-IP (server export list) | ✅ |
|
||||||
|
| **SFTP** (SSH) | Pure-Rust in-process client (`russh`) | Password **or** SSH key · host-key TOFU | ✅ |
|
||||||
|
| **SMB** / CIFS | Userspace `smbclient` | Guest or username/password | — |
|
||||||
|
|
||||||
|
¹ Range support lets clients seek into a multi-GB ISO without downloading what comes
|
||||||
|
before it — needed for kernel/initrd extraction and `httpdisk`-style boots. NFS and SFTP
|
||||||
|
expose explicit offsets; the SMB CLI streams sequentially, so SMB-sourced ISOs serve whole-file.
|
||||||
|
|
||||||
|
## Supported client architectures
|
||||||
|
|
||||||
|
| DHCP option 93 | Architecture | Firmware served |
|
||||||
|
|----------------|--------------|-----------------|
|
||||||
|
| `0x0000` | Legacy x86 BIOS | `undionly.kpxe` |
|
||||||
|
| `0x0006` | IA32 UEFI | `snponly-i386.efi` |
|
||||||
|
| `0x0007` / `0x0009` | x86_64 UEFI | `snponly.efi` |
|
||||||
|
| `0x000B` | ARM64 UEFI | `snponly-arm64.efi` |
|
||||||
|
|
||||||
|
UEFI firmware that advertises `HTTPClient` (option 60) skips TFTP entirely and is handed an HTTP URL.
|
||||||
|
|
||||||
|
## The boot menu, on a real client
|
||||||
|
|
||||||
```
|
```
|
||||||
OpenPXE - network boot menu
|
OpenPXE — network boot menu
|
||||||
|
|
||||||
------------------------- Default -------------------------
|
------------------------- 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 / Shell /
|
Tools > Utilities / OpenPXE Shell / NIC Info / Reboot
|
||||||
NIC Info / Reboot /
|
|
||||||
Exit and continue BIOS
|
|
||||||
---------------------- Queued Deployment ------------------
|
---------------------- Queued Deployment ------------------
|
||||||
Queued Deployment (join queue)
|
Queued Deployment (join queue)
|
||||||
```
|
```
|
||||||
|
|
||||||
Linux/Windows submenus show file sizes iVentoy-style:
|
Linux/Windows submenus list images iVentoy-style with sizes:
|
||||||
|
|
||||||
```
|
```
|
||||||
OpenPXE - Linux Installers
|
OpenPXE — Linux Installers
|
||||||
|
|
||||||
[ 4376 MB] CentOS-7-x86_64-DVD-1810
|
|
||||||
[ 2002 MB] Fedora-Workstation-Live-x86_64-38-1.6
|
|
||||||
[ 4699 MB] ubuntu-22.04.2-desktop-amd64
|
[ 4699 MB] ubuntu-22.04.2-desktop-amd64
|
||||||
|
[ 2002 MB] Fedora-Workstation-Live-x86_64-38-1.6
|
||||||
< Back to main menu
|
< Back to main menu
|
||||||
```
|
```
|
||||||
|
|
||||||
iPXE never appears in the UI — the whole hierarchy above is generated from
|
The entire hierarchy is generated from what you upload and toggle — iPXE never surfaces.
|
||||||
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}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Why a custom SCC?
|
The bundled `openpxe-scc` grants exactly `hostNetwork` (CNI overlays don't deliver L2
|
||||||
|
broadcast into pod netns) and `NET_BIND_SERVICE` (to bind ports <1024) — nothing else.
|
||||||
|
No raw sockets, no privileged mode. The Route covers `80/TCP`; PXE clients reach UDP
|
||||||
|
67/69/4011 on the node's host IP directly.
|
||||||
|
|
||||||
The default `restricted-v2` blocks `hostNetwork` and all capabilities. PXE
|
## Health & observability
|
||||||
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.
|
|
||||||
|
|
||||||
### What's on host ports
|
| Endpoint | Purpose |
|
||||||
|
|----------|---------|
|
||||||
| Port | Proto | Purpose |
|
| `/healthz` | Liveness — always 200 if the HTTP stack is up. |
|
||||||
|----------|-------|---------------------------------|
|
| `/readyz` | Readiness — 200 only once iPXE firmware is bundled and the ISO dir is reachable. |
|
||||||
| 67 | UDP | DHCP server (proxy replies) |
|
| `/api/status` | Full JSON: version, assets, counts, live settings, share + SMB state. |
|
||||||
| 69 | UDP | TFTP |
|
| `/metrics` | Prometheus text format — DHCP replies by arch, TFTP/HTTP counts, queue gauges, uptime. |
|
||||||
| 4011 | UDP | PXE Boot Server discovery |
|
|
||||||
| 80 | TCP | Web UI + HTTP boot assets |
|
|
||||||
|
|
||||||
The OpenShift Route only covers 80/TCP. Clients on the PXE network talk to
|
|
||||||
the node's host IP directly for UDP.
|
|
||||||
|
|
||||||
## Windows support
|
|
||||||
|
|
||||||
Enabled by toggling **Windows ISO support** under Settings. The flow:
|
|
||||||
|
|
||||||
1. Upload a stock Microsoft Windows install ISO (vanilla, no pre-processing).
|
|
||||||
2. On upload, OpenPXE extracts the ISO and uses `wimlib-imagex` to rewrite
|
|
||||||
image index 2 (WinPE) of `sources/boot.wim`. It injects exactly two
|
|
||||||
plain-text files:
|
|
||||||
- `Windows/System32/winpeshl.ini` — tells WinPE to run `startnet.cmd`.
|
|
||||||
- `Windows/System32/startnet.cmd` — runs `wpeinit`, waits for the SMB
|
|
||||||
host to be reachable, `net use Z: \\<server>\<share> /user:guest`,
|
|
||||||
then `Z:\setup.exe`.
|
|
||||||
3. The container's Samba `smbd` serves the extracted install tree on :445.
|
|
||||||
4. The client gets chainloaded into wimboot → patched WinPE → Windows Setup
|
|
||||||
running off the SMB share. **Every binary the client executes is stock
|
|
||||||
Microsoft-signed.**
|
|
||||||
|
|
||||||
### What we never do
|
|
||||||
|
|
||||||
- Ship drivers — signed, test-signed, or otherwise — that load on the client.
|
|
||||||
- Install certificates into the target's trust store or WinPE boot policy.
|
|
||||||
- Recommend `bcdedit /set testsigning on` or any equivalent signing-policy
|
|
||||||
weakening.
|
|
||||||
|
|
||||||
### Credit & limitations
|
|
||||||
|
|
||||||
The SMB-based approach is adapted from [Bootimus](https://github.com/garybowers/bootimus)
|
|
||||||
(Apache-2.0). Re-implemented in Rust; no code was copied verbatim. Known
|
|
||||||
operational constraints inherited from the design:
|
|
||||||
|
|
||||||
- **Port 445 must be directly reachable from PXE clients.** `net use`
|
|
||||||
ignores alternate ports. In OpenShift this means `hostPort: 445` on the
|
|
||||||
deployment; on a host that already runs SMB it will collide.
|
|
||||||
- Windows 10/11 client SKUs are the tested target. Server SKUs untested.
|
|
||||||
- Hardware with NICs/storage controllers missing from WinPE's bundled
|
|
||||||
drivers will need a driver-pack injection step (not yet implemented).
|
|
||||||
|
|
||||||
## Queued Deployment
|
|
||||||
|
|
||||||
The coordinated launch flow, end to end:
|
|
||||||
|
|
||||||
1. A client boots and picks **Queued Deployment** in the PXE menu (or falls
|
|
||||||
through on timeout with the default `timeout_action`).
|
|
||||||
2. The client joins the queue, gets a numbered queue position, and enters a
|
|
||||||
long-poll loop (25s per request, auto-renewed).
|
|
||||||
3. In the web UI's **Queued Deployment** tab, the operator sees each waiting
|
|
||||||
client with its MAC, IP, arch, and position.
|
|
||||||
4. The operator selects an image and clicks **Launch for all waiting**.
|
|
||||||
The server broadcasts the assignment to every queued client via a
|
|
||||||
`tokio::sync::Notify`; each client's next poll returns the boot script
|
|
||||||
for the chosen image.
|
|
||||||
5. Every client chains the same image at effectively the same moment. The
|
|
||||||
queue stays visible until the operator releases entries, which keeps a
|
|
||||||
useful audit trail during hardware testing.
|
|
||||||
|
|
||||||
No user-facing iPXE anywhere in this flow. The client only ever runs
|
|
||||||
scripts we generate; the operator only interacts with the web UI.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
See [`docs/architecture.md`](docs/architecture.md) for the protocol stack,
|
Workspace of focused crates — `core`, `dhcp-proxy`, `tftp`, `http-api`, `iso-store`,
|
||||||
crate layout, and the full decision log.
|
`ipxe-assets`, `webui`, and the `openpxe` binary. See
|
||||||
|
[`docs/architecture.md`](docs/architecture.md) for the protocol stack, crate layout, and
|
||||||
|
the full decision log.
|
||||||
|
|
||||||
## Licence
|
## License
|
||||||
|
|
||||||
MIT OR Apache-2.0.
|
Dual-licensed under **MIT OR Apache-2.0** — use whichever fits your project.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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
|
||||||
@@ -38,6 +39,9 @@ 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
|
# v0.5.1: generate a throwaway self-signed signing cert/key so SAML
|
||||||
# verification tests can produce genuinely signed SAMLResponses.
|
# verification tests can produce genuinely signed SAMLResponses.
|
||||||
rcgen = "0.13"
|
rcgen = "0.13"
|
||||||
|
|||||||
+189
-10
@@ -23,6 +23,36 @@ 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 {
|
||||||
@@ -39,21 +69,73 @@ 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`.
|
/// all-drivers-included `ipxe.efi`. Equivalent to
|
||||||
|
/// [`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> {
|
||||||
Some(match self {
|
self.ipxe_bootfile_mode(DriverMode::Firmware)
|
||||||
Self::LegacyX86 => "undionly.kpxe",
|
}
|
||||||
Self::Ia32Uefi => "snponly-i386.efi",
|
|
||||||
Self::X64Uefi => "snponly.efi",
|
/// iPXE binary filename for this architecture under a given network
|
||||||
// ARM32 UEFI: upstream boot.ipxe.org does not publish a prebuilt
|
/// [`DriverMode`].
|
||||||
// snponly variant for this arch. We return None so the DHCP
|
///
|
||||||
// proxy declines rather than advertising a file we can't serve.
|
/// * [`DriverMode::Firmware`] — the `snponly`/`undionly` builds that reuse
|
||||||
Self::Arm32Uefi | Self::Unknown(_) => return None,
|
/// the firmware's UNDI/SNP NIC stack. Smallest, and the most reliable
|
||||||
Self::Arm64Uefi => "snponly-arm64.efi",
|
/// choice for chainloading because the firmware just proved its network
|
||||||
|
/// 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 {
|
||||||
@@ -133,6 +215,103 @@ mod tests {
|
|||||||
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"));
|
||||||
|
|||||||
@@ -125,9 +125,7 @@ impl AdminStore {
|
|||||||
{
|
{
|
||||||
let mut g = self.inner.write();
|
let mut g = self.inner.write();
|
||||||
if g.admin.is_some() {
|
if g.admin.is_some() {
|
||||||
return Err(Error::Invalid(
|
return Err(Error::Invalid("admin account already configured".into()));
|
||||||
"admin account already configured".into(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
g.admin = Some(admin.clone());
|
g.admin = Some(admin.clone());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
//! 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
//! 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,11 +12,9 @@ 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)]
|
||||||
@@ -32,8 +30,6 @@ 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)]
|
||||||
@@ -66,7 +62,6 @@ impl ClientRegistry {
|
|||||||
first_seen: now,
|
first_seen: now,
|
||||||
last_seen: now,
|
last_seen: now,
|
||||||
events: Vec::new(),
|
events: Vec::new(),
|
||||||
selected_target: None,
|
|
||||||
});
|
});
|
||||||
entry.last_seen = now;
|
entry.last_seen = now;
|
||||||
if ip.is_some() {
|
if ip.is_some() {
|
||||||
@@ -84,13 +79,6 @@ 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();
|
||||||
@@ -99,9 +87,4 @@ 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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+153
-40
@@ -1,3 +1,5 @@
|
|||||||
|
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};
|
||||||
@@ -38,10 +40,6 @@ 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)]
|
||||||
@@ -56,6 +54,9 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,8 +101,6 @@ 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(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,48 +128,162 @@ 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()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply environment variable overrides. Env var names follow the pattern
|
/// Load configuration with layered precedence (v0.5.4, via `figment`):
|
||||||
/// `OPENPXE_<SECTION>_<FIELD>`, uppercase. Unknown vars are ignored.
|
/// built-in [`Default`] → optional TOML file → `OPENPXE_*` environment
|
||||||
/// Call this after loading the TOML file so env takes precedence.
|
/// (highest). Replaces the old `from_toml_file` + `apply_env` two-step
|
||||||
pub fn apply_env(&mut self) {
|
/// and now covers **every** field automatically (the previous hand-rolled
|
||||||
if let Ok(v) = std::env::var("OPENPXE_HTTP_PORT") {
|
/// mapping silently skipped `unattended_dir`, the bind addresses, etc.).
|
||||||
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Ok(v) = std::env::var("OPENPXE_TFTP_PORT") {
|
fig = fig.merge(env_provider());
|
||||||
if let Ok(p) = v.parse() {
|
fig.extract()
|
||||||
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() {
|
/// The `OPENPXE_*` environment provider. Maps the historical flat variable
|
||||||
self.network.dhcp_port = p;
|
/// names onto the nested [`Config`] fields, and also accepts the explicit
|
||||||
}
|
/// `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_PUBLIC_IP") {
|
/// top-level keys that `Config` ignores on extract.
|
||||||
if let Ok(ip) = v.parse() {
|
fn env_provider() -> Env {
|
||||||
self.server.public_ip = Some(ip);
|
Env::prefixed("OPENPXE_")
|
||||||
}
|
.map(|key| {
|
||||||
}
|
// Lowercase so the match is robust regardless of how the OS
|
||||||
if let Ok(v) = std::env::var("OPENPXE_DHCP_MODE") {
|
// reports the var's case.
|
||||||
self.network.dhcp_mode = match v.to_ascii_lowercase().as_str() {
|
let k = key.as_str().to_ascii_lowercase();
|
||||||
"proxy" => DhcpMode::Proxy,
|
let mapped = match k.as_str() {
|
||||||
"disabled" | "off" | "none" => DhcpMode::Disabled,
|
"http_port" => "server.http_port",
|
||||||
_ => self.network.dhcp_mode,
|
"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()
|
||||||
|
})
|
||||||
|
.split(".")
|
||||||
}
|
}
|
||||||
if let Ok(v) = std::env::var("OPENPXE_ISO_DIR") {
|
|
||||||
self.paths.iso_dir = PathBuf::from(v);
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
// figment's `Jail::expect_with` closure returns `Result<(), figment::Error>`
|
||||||
|
// and `figment::Error` is large; that's the library's API, not ours.
|
||||||
|
#![allow(clippy::result_large_err)]
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[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(())
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if let Ok(v) = std::env::var("OPENPXE_WORK_DIR") {
|
|
||||||
self.paths.work_dir = PathBuf::from(v);
|
#[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(())
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if let Ok(v) = std::env::var("OPENPXE_IPXE_DIR") {
|
|
||||||
self.paths.ipxe_dir = PathBuf::from(v);
|
#[test]
|
||||||
}
|
fn dhcp_mode_off_alias_maps_to_disabled() {
|
||||||
if let Ok(v) = std::env::var("OPENPXE_SMB_DIR") {
|
figment::Jail::expect_with(|jail| {
|
||||||
self.paths.smb_dir = PathBuf::from(v);
|
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(())
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
//! 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("&"),
|
||||||
|
'<' => out.push_str("<"),
|
||||||
|
'>' => out.push_str(">"),
|
||||||
|
'"' => out.push_str("""),
|
||||||
|
'\'' => out.push_str("'"),
|
||||||
|
_ => 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&b<c>"d'e");
|
||||||
|
assert_eq!(xml_escape("plain text"), "plain text");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,12 @@
|
|||||||
pub mod arch;
|
pub mod arch;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod boot_log;
|
pub mod boot_log;
|
||||||
|
pub mod boot_rules;
|
||||||
|
pub mod boot_tokens;
|
||||||
pub mod branding;
|
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 host_bindings;
|
pub mod host_bindings;
|
||||||
pub mod log_bus;
|
pub mod log_bus;
|
||||||
@@ -20,9 +23,11 @@ pub mod settings;
|
|||||||
pub mod sso;
|
pub mod sso;
|
||||||
pub mod wol;
|
pub mod wol;
|
||||||
|
|
||||||
pub use arch::{ClientArch, FirmwareClass};
|
pub use arch::{ClientArch, DriverMode, FirmwareClass};
|
||||||
pub use auth::{AdminAccount, AdminPublic, AdminStore};
|
pub use auth::{AdminAccount, AdminPublic, AdminStore};
|
||||||
pub use boot_log::{BootEvent, BootLog};
|
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 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};
|
||||||
@@ -35,4 +40,4 @@ pub use profile::DeployProfile;
|
|||||||
pub use queue::{DeploymentQueue, QueueEntry};
|
pub use queue::{DeploymentQueue, QueueEntry};
|
||||||
pub use saml::{IdpMetadata, SamlError, SpParams, VerifiedPrincipal, VerifiedResponse};
|
pub use saml::{IdpMetadata, SamlError, SpParams, VerifiedPrincipal, VerifiedResponse};
|
||||||
pub use settings::{Settings, SettingsStore, TimeoutAction};
|
pub use settings::{Settings, SettingsStore, TimeoutAction};
|
||||||
pub use sso::{SsoConfig, SsoStore};
|
pub use sso::{SsoConfig, SsoLoginInfo, SsoStore};
|
||||||
|
|||||||
@@ -303,7 +303,10 @@ mod tests {
|
|||||||
smtp_host: "smtp.example.com".into(),
|
smtp_host: "smtp.example.com".into(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
assert!(matches!(r, Err(Error::Invalid(_))), "missing recipient should reject");
|
assert!(
|
||||||
|
matches!(r, Err(Error::Invalid(_))),
|
||||||
|
"missing recipient should reject"
|
||||||
|
);
|
||||||
s.replace(NotifyConfig {
|
s.replace(NotifyConfig {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
kind: NotifyKind::Smtp,
|
kind: NotifyKind::Smtp,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
//! appended as the `SAMLRequest` query parameter. AuthnRequests are sent
|
//! appended as the `SAMLRequest` query parameter. AuthnRequests are sent
|
||||||
//! unsigned in this release (the IdP must not require client signatures).
|
//! unsigned in this release (the IdP must not require client signatures).
|
||||||
|
|
||||||
use std::fmt::Write as _;
|
|
||||||
use std::io::Write as _;
|
use std::io::Write as _;
|
||||||
|
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
@@ -15,6 +14,7 @@ use time::format_description::well_known::Rfc3339;
|
|||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
use super::{SamlError, SpParams};
|
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_PROTOCOL: &str = "urn:oasis:names:tc:SAML:2.0:protocol";
|
||||||
const NS_ASSERTION: &str = "urn:oasis:names:tc:SAML:2.0:assertion";
|
const NS_ASSERTION: &str = "urn:oasis:names:tc:SAML:2.0:assertion";
|
||||||
@@ -79,37 +79,8 @@ fn deflate_base64(xml: &str) -> Result<String, SamlError> {
|
|||||||
Ok(base64::engine::general_purpose::STANDARD.encode(compressed))
|
Ok(base64::engine::general_purpose::STANDARD.encode(compressed))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Percent-encode a query-string component (RFC 3986 unreserved set passes
|
// `pct_encode` + `xml_escape` now live in `openpxe_core::encoding` (v0.5.4)
|
||||||
/// through; everything else is `%XX`).
|
// — imported above.
|
||||||
fn pct_encode(s: &str) -> String {
|
|
||||||
let mut out = String::with_capacity(s.len() * 3);
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
fn xml_escape(s: &str) -> String {
|
|
||||||
let mut out = String::with_capacity(s.len());
|
|
||||||
for c in s.chars() {
|
|
||||||
match c {
|
|
||||||
'&' => out.push_str("&"),
|
|
||||||
'<' => out.push_str("<"),
|
|
||||||
'>' => out.push_str(">"),
|
|
||||||
'"' => out.push_str("""),
|
|
||||||
'\'' => out.push_str("'"),
|
|
||||||
_ => out.push(c),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
|
|
||||||
use super::{SamlError, SpParams};
|
use super::{SamlError, SpParams};
|
||||||
|
use crate::encoding::xml_escape;
|
||||||
|
|
||||||
/// SAML 2.0 binding URIs.
|
/// SAML 2.0 binding URIs.
|
||||||
pub const BINDING_REDIRECT: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect";
|
pub const BINDING_REDIRECT: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect";
|
||||||
@@ -148,21 +149,7 @@ fn node_text(n: &roxmltree::Node<'_, '_>) -> String {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Minimal XML attribute/text escaping for the values we interpolate.
|
// `xml_escape` now lives in `openpxe_core::encoding` (v0.5.4) — imported above.
|
||||||
fn xml_escape(s: &str) -> String {
|
|
||||||
let mut out = String::with_capacity(s.len());
|
|
||||||
for c in s.chars() {
|
|
||||||
match c {
|
|
||||||
'&' => out.push_str("&"),
|
|
||||||
'<' => out.push_str("<"),
|
|
||||||
'>' => out.push_str(">"),
|
|
||||||
'"' => out.push_str("""),
|
|
||||||
'\'' => out.push_str("'"),
|
|
||||||
_ => out.push(c),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|||||||
@@ -73,6 +73,23 @@ impl SsoConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// In-memory + on-disk SSO settings registry.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SsoStore {
|
pub struct SsoStore {
|
||||||
@@ -111,6 +128,19 @@ impl SsoStore {
|
|||||||
self.inner.read().clone()
|
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
|
/// Replace the whole config in one shot. Light validation: metadata
|
||||||
/// XML and URL are length-capped so an operator can't OOM us by
|
/// 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,
|
/// pasting a 10 GiB blob; the IdP UI tab clamps the input visually,
|
||||||
|
|||||||
@@ -181,10 +181,7 @@ mod tests {
|
|||||||
Ipv4Addr::new(192, 168, 1, 255)
|
Ipv4Addr::new(192, 168, 1, 255)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
subnet_broadcast(
|
subnet_broadcast(Ipv4Addr::new(10, 5, 3, 7), Ipv4Addr::new(255, 255, 0, 0)),
|
||||||
Ipv4Addr::new(10, 5, 3, 7),
|
|
||||||
Ipv4Addr::new(255, 255, 0, 0)
|
|
||||||
),
|
|
||||||
Ipv4Addr::new(10, 5, 255, 255)
|
Ipv4Addr::new(10, 5, 255, 255)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -196,7 +193,8 @@ mod tests {
|
|||||||
// and confirm send_magic transmits the exact 102-byte packet.
|
// and confirm send_magic transmits the exact 102-byte packet.
|
||||||
let rx = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).unwrap();
|
let rx = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||||
let port = rx.local_addr().unwrap().port();
|
let port = rx.local_addr().unwrap().port();
|
||||||
rx.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap();
|
rx.set_read_timeout(Some(std::time::Duration::from_secs(2)))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let packet = magic_packet([0x0a, 0x1b, 0x2c, 0x3d, 0x4e, 0x5f]);
|
let packet = magic_packet([0x0a, 0x1b, 0x2c, 0x3d, 0x4e, 0x5f]);
|
||||||
let sent = send_magic(&packet, &[Ipv4Addr::LOCALHOST], port).unwrap();
|
let sent = send_magic(&packet, &[Ipv4Addr::LOCALHOST], port).unwrap();
|
||||||
|
|||||||
@@ -18,3 +18,9 @@ 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"
|
||||||
|
|||||||
@@ -0,0 +1,494 @@
|
|||||||
|
//! 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,9 @@
|
|||||||
//! 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;
|
||||||
|
|||||||
@@ -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, FirmwareClass};
|
use openpxe_core::{ClientArch, DriverMode, FirmwareClass};
|
||||||
use std::net::Ipv4Addr;
|
use std::net::Ipv4Addr;
|
||||||
|
|
||||||
/// Where the reply directs the client next.
|
/// Where the reply directs the client next.
|
||||||
@@ -31,6 +31,11 @@ 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,
|
||||||
}
|
}
|
||||||
@@ -44,17 +49,27 @@ 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 GET, so this stays static across firmwares. The arch is
|
||||||
|
// 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}}",
|
"{}/boot.ipxe?mac=${{mac}}&arch={}",
|
||||||
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 ipxe.efi over HTTP;
|
// pointing at an EFI executable. We serve the iPXE EFI build for
|
||||||
// it'll then do the same script-fetch the iPXE path does.
|
// the negotiated driver mode over HTTP; it'll then do the same
|
||||||
let name = ctx.arch.ipxe_bootfile().unwrap_or("snponly.efi");
|
// script-fetch the iPXE path does.
|
||||||
|
// `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/{}",
|
"{}/ipxe/{}",
|
||||||
@@ -63,7 +78,7 @@ pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FirmwareClass::PxeClient => match ctx.arch.ipxe_bootfile() {
|
FirmwareClass::PxeClient => match ctx.arch.bootfile_with_fallback(ctx.driver_mode) {
|
||||||
Some(name) => BootDirective::TftpIpxe {
|
Some(name) => BootDirective::TftpIpxe {
|
||||||
filename: name.to_string(),
|
filename: name.to_string(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
//! 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::{ClientArch, ClientEvent, ClientRegistry, FirmwareClass};
|
use openpxe_core::{
|
||||||
|
BootRulesStore, ClientArch, ClientEvent, ClientRegistry, DriverMode, 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};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -18,9 +21,19 @@ 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,
|
||||||
@@ -29,6 +42,8 @@ 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,
|
||||||
@@ -38,6 +53,8 @@ impl DhcpProxyServer {
|
|||||||
public_base_url,
|
public_base_url,
|
||||||
clients,
|
clients,
|
||||||
metrics,
|
metrics,
|
||||||
|
escalation,
|
||||||
|
rules,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,11 +143,38 @@ 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);
|
||||||
@@ -154,7 +198,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, dest=%dest, directive=?directive,
|
mac=%mac, arch=arch.as_str(), class=?class, driver=?driver_mode, dest=%dest, directive=?directive,
|
||||||
"PXE reply sent"
|
"PXE reply sent"
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -213,11 +257,16 @@ fn bind_udp(bind: IpAddr, port: u16, broadcast: bool) -> anyhow::Result<UdpSocke
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn format_mac(chaddr: &[u8]) -> String {
|
fn format_mac(chaddr: &[u8]) -> String {
|
||||||
let take = chaddr.iter().take(6).copied().collect::<Vec<_>>();
|
use std::fmt::Write;
|
||||||
take.iter()
|
// One allocation — this runs for every PXE datagram we answer.
|
||||||
.map(|b| format!("{b:02x}"))
|
let mut s = String::with_capacity(17);
|
||||||
.collect::<Vec<_>>()
|
for (i, b) in chaddr.iter().take(6).enumerate() {
|
||||||
.join(":")
|
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)
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ thiserror.workspace = true
|
|||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
bytes.workspace = true
|
bytes.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
mime.workspace = true
|
|
||||||
mime_guess.workspace = true
|
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
# v0.4.5 Forms auth: lock-free session store and cookie helpers.
|
# v0.4.5 Forms auth: lock-free session store and cookie helpers.
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
@@ -61,3 +59,9 @@ image = { version = "0.25", default-features = false, features = ["png"] }
|
|||||||
# replay, and IdP-initiated-gating flows exercise real signatures.
|
# replay, and IdP-initiated-gating flows exercise real signatures.
|
||||||
rcgen = "0.13"
|
rcgen = "0.13"
|
||||||
bergshamra = { workspace = true }
|
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"
|
||||||
|
|||||||
+616
-170
File diff suppressed because it is too large
Load Diff
@@ -154,16 +154,28 @@ pub fn session_cookie(session: &str) -> String {
|
|||||||
|
|
||||||
fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
|
fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||||
// `Cookie: a=b; c=d` parsing — small enough not to drag in a crate.
|
// `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()?;
|
let raw = headers.get(header::COOKIE)?.to_str().ok()?;
|
||||||
for part in raw.split(';') {
|
for part in raw.split(';') {
|
||||||
let part = part.trim();
|
let part = part.trim();
|
||||||
if let Some(v) = part.strip_prefix(&format!("{SESSION_COOKIE}=")) {
|
if let Some(v) = part
|
||||||
|
.strip_prefix(SESSION_COOKIE)
|
||||||
|
.and_then(|rest| rest.strip_prefix('='))
|
||||||
|
{
|
||||||
return Some(v.to_string());
|
return Some(v.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
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 ────────────────────────────────────────────────────────────
|
// ── Middleware ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Return `true` if `path` is on the allowlist and should bypass the
|
/// Return `true` if `path` is on the allowlist and should bypass the
|
||||||
@@ -246,7 +258,14 @@ pub async fn api_setup(State(state): State<AppState>, Json(body): Json<SetupBody
|
|||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
match state.admin.bootstrap(&body.username, &body.password) {
|
// 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_) => {
|
Ok(pub_) => {
|
||||||
let session = state.sessions.create(&pub_.username);
|
let session = state.sessions.create(&pub_.username);
|
||||||
login_response(StatusCode::CREATED, &pub_, &session)
|
login_response(StatusCode::CREATED, &pub_, &session)
|
||||||
@@ -271,8 +290,13 @@ pub struct LoginBody {
|
|||||||
pub async fn api_login(State(state): State<AppState>, Json(body): Json<LoginBody>) -> Response {
|
pub async fn api_login(State(state): State<AppState>, Json(body): Json<LoginBody>) -> Response {
|
||||||
// Brief, deliberately vague — "invalid credentials" rather than
|
// Brief, deliberately vague — "invalid credentials" rather than
|
||||||
// "no such user" / "wrong password". Same anti-enumeration posture
|
// "no such user" / "wrong password". Same anti-enumeration posture
|
||||||
// as Sonarr/Radarr.
|
// as Sonarr/Radarr. The bcrypt verify is ~100-200 ms of pure CPU on
|
||||||
let pub_ = match state.admin.verify(&body.username, &body.password) {
|
// 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(Some(u)) => u,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
return (
|
return (
|
||||||
@@ -319,6 +343,12 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
|
|||||||
// and the logo asset is public, so this leaks nothing sensitive.
|
// and the logo asset is public, so this leaks nothing sensitive.
|
||||||
let has_custom_logo = state.branding.has_any_web_logo();
|
let has_custom_logo = state.branding.has_any_web_logo();
|
||||||
let logo_rev = state.branding.logo_rev();
|
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() {
|
if !state.admin.is_configured() {
|
||||||
return (
|
return (
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
@@ -327,6 +357,7 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
|
|||||||
"authenticated": false,
|
"authenticated": false,
|
||||||
"has_custom_logo": has_custom_logo,
|
"has_custom_logo": has_custom_logo,
|
||||||
"logo_rev": logo_rev,
|
"logo_rev": logo_rev,
|
||||||
|
"sso": sso,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
@@ -343,6 +374,7 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
|
|||||||
"session_user": u,
|
"session_user": u,
|
||||||
"has_custom_logo": has_custom_logo,
|
"has_custom_logo": has_custom_logo,
|
||||||
"logo_rev": logo_rev,
|
"logo_rev": logo_rev,
|
||||||
|
"sso": sso,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.into_response(),
|
.into_response(),
|
||||||
@@ -353,6 +385,7 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
|
|||||||
"authenticated": false,
|
"authenticated": false,
|
||||||
"has_custom_logo": has_custom_logo,
|
"has_custom_logo": has_custom_logo,
|
||||||
"logo_rev": logo_rev,
|
"logo_rev": logo_rev,
|
||||||
|
"sso": sso,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.into_response(),
|
.into_response(),
|
||||||
@@ -385,11 +418,18 @@ pub async fn api_update_credentials(
|
|||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
let result = state.admin.update_credentials(
|
// 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.current_password,
|
||||||
body.new_username.as_deref(),
|
body.new_username.as_deref(),
|
||||||
body.new_password.as_deref(),
|
body.new_password.as_deref(),
|
||||||
);
|
)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| Err(openpxe_core::Error::Other(e.into())));
|
||||||
match result {
|
match result {
|
||||||
Ok(pub_) => {
|
Ok(pub_) => {
|
||||||
state.sessions.revoke_all();
|
state.sessions.revoke_all();
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
//! 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
//! 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,16 +61,24 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
|
|||||||
// returns a full-screen 1024×768 PNG now — the operator's logo on a
|
// 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
|
// dark field, or a default OpenPXE mark when none is uploaded. The
|
||||||
// `--top 290` reserves the top band (where the logo paints) so the
|
// `--top 290` reserves the top band (where the logo paints) so the
|
||||||
// menu text lands below it. On an iPXE build *with* `IMAGE_PNG` +
|
// menu text lands below it.
|
||||||
// `CONSOLE_FRAMEBUFFER` (our x86_64 UEFI binaries, built from source
|
//
|
||||||
// — see deploy/docker/Dockerfile) this paints the background and
|
// v0.5.7: gate the whole command behind `iseq ${platform} efi`.
|
||||||
// overlays the menu. On a build *without* PNG support (the fetched
|
// `console --picture` needs IMAGE_PNG + CONSOLE_FRAMEBUFFER, which
|
||||||
// BIOS/i386/arm64 binaries) the whole `console --picture …` command
|
// only our from-source UEFI binaries carry (x86_64/arm64 UEFI — see
|
||||||
// fails and the `|| console` resets to a clean full-screen text
|
// deploy/docker/Dockerfile). The fetched BIOS `undionly.kpxe` has
|
||||||
// menu. Either way there's no ASCII placeholder anymore.
|
// 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!(
|
let _ = writeln!(
|
||||||
s,
|
s,
|
||||||
"console --picture {base}/branding/pxe-logo --top 290 || console"
|
"iseq ${{platform}} efi && console --picture {base}/branding/pxe-logo --top 290 || console"
|
||||||
);
|
);
|
||||||
// Map iPXE's ${{buildarch}} + ${{platform}} into the human form the
|
// Map iPXE's ${{buildarch}} + ${{platform}} into the human form the
|
||||||
// user asked for (e.g. "x86 BIOS", "x86_64 UEFI", "arm64 UEFI").
|
// user asked for (e.g. "x86 BIOS", "x86_64 UEFI", "arm64 UEFI").
|
||||||
@@ -101,12 +109,13 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
|
|||||||
} else {
|
} else {
|
||||||
let _ = writeln!(s, "item --gap -- (no Linux ISOs uploaded)");
|
let _ = writeln!(s, "item --gap -- (no Linux ISOs uploaded)");
|
||||||
}
|
}
|
||||||
if settings.windows_enabled && has_family(isos, is_windows_family) {
|
// v0.5.8: Windows just works — no Settings toggle. Show the Windows
|
||||||
|
// installers submenu whenever a Windows ISO is present; entries boot
|
||||||
|
// via HTTP sanboot of the raw ISO, so no SMB/extraction is required.
|
||||||
|
if has_family(isos, is_windows_family) {
|
||||||
let _ = writeln!(s, "item windows Windows Installers >");
|
let _ = writeln!(s, "item windows Windows Installers >");
|
||||||
} else if settings.windows_enabled {
|
|
||||||
let _ = writeln!(s, "item --gap -- (no Windows ISOs uploaded)");
|
|
||||||
} else {
|
} else {
|
||||||
let _ = writeln!(s, "item --gap -- (Windows support disabled in Settings)");
|
let _ = writeln!(s, "item --gap -- (no Windows ISOs uploaded)");
|
||||||
}
|
}
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
s,
|
s,
|
||||||
@@ -693,6 +702,22 @@ mod password_tests {
|
|||||||
assert!(s.contains("arm64 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]
|
#[test]
|
||||||
fn generated_scripts_do_not_emit_bare_or_trailing_fallbacks() {
|
fn generated_scripts_do_not_emit_bare_or_trailing_fallbacks() {
|
||||||
let settings = Settings::default();
|
let settings = Settings::default();
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
|
|
||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod auth;
|
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;
|
||||||
|
|||||||
@@ -105,7 +105,11 @@ async fn send_email(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(),
|
|||||||
.trim()
|
.trim()
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|e| format!("invalid To address '{}': {e}", cfg.smtp_to))?)
|
.map_err(|e| format!("invalid To address '{}': {e}", cfg.smtp_to))?)
|
||||||
.subject(if subject.is_empty() { "OpenPXE" } else { subject })
|
.subject(if subject.is_empty() {
|
||||||
|
"OpenPXE"
|
||||||
|
} else {
|
||||||
|
subject
|
||||||
|
})
|
||||||
.body(body.to_string())
|
.body(body.to_string())
|
||||||
.map_err(|e| format!("could not build email: {e}"))?;
|
.map_err(|e| format!("could not build email: {e}"))?;
|
||||||
|
|
||||||
|
|||||||
@@ -336,3 +336,35 @@ fn redirect_with_session(location: &str, session: &str) -> Response {
|
|||||||
IntoResponse::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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -2,13 +2,19 @@ use crate::auth::SessionStore;
|
|||||||
use crate::saml_routes::SamlRuntime;
|
use crate::saml_routes::SamlRuntime;
|
||||||
use crate::uploads::UploadSessions;
|
use crate::uploads::UploadSessions;
|
||||||
use openpxe_core::{
|
use openpxe_core::{
|
||||||
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
|
AdminStore, BootLog, BootRulesStore, BootTokens, BrandingStore, ClientRegistry,
|
||||||
Metrics, NotifyStore, SettingsStore, SsoStore,
|
DeploymentQueue, HostBindings, LogBus, Metrics, NotifyStore, SettingsStore, SsoStore,
|
||||||
|
};
|
||||||
|
use openpxe_iso_store::{
|
||||||
|
IsoStore, NfsShareManager, SftpShareManager, SmbManager, SmbShareManager, UnattendedStore,
|
||||||
};
|
};
|
||||||
use openpxe_iso_store::{IsoStore, NfsShareManager, 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,
|
||||||
@@ -23,10 +29,24 @@ pub struct AppState {
|
|||||||
/// every `/boot/<entry>.ipxe` chain that goes on to serve a script
|
/// every `/boot/<entry>.ipxe` chain that goes on to serve a script
|
||||||
/// (i.e. an image actually starting to install on a machine).
|
/// (i.e. an image actually starting to install on a machine).
|
||||||
pub boot_log: BootLog,
|
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-controlled UI overrides (custom logo). When the
|
||||||
/// operator hasn't uploaded anything, the WebUI serves the bundled
|
/// operator hasn't uploaded anything, the WebUI serves the bundled
|
||||||
/// rainbow-horizon mark.
|
/// rainbow-horizon mark.
|
||||||
pub branding: BrandingStore,
|
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
|
/// Forms-auth admin record + first-run bootstrap state. When
|
||||||
/// `admin.is_configured() == false`, the auth middleware passes
|
/// `admin.is_configured() == false`, the auth middleware passes
|
||||||
/// every request through and `/api/me` reports `setup_required`.
|
/// every request through and `/api/me` reports `setup_required`.
|
||||||
@@ -67,6 +87,13 @@ pub struct AppState {
|
|||||||
/// In-process (no subprocess); supports HTTP Range requests on
|
/// In-process (no subprocess); supports HTTP Range requests on
|
||||||
/// NFS-sourced ISOs because NFSv3 READ3 takes an explicit offset.
|
/// NFS-sourced ISOs because NFSv3 READ3 takes an explicit offset.
|
||||||
pub nfs_shares: NfsShareManager,
|
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 /
|
/// v0.5.2: uploaded unattended-install answer files (Kickstart /
|
||||||
/// Preseed / Autoinstall / Windows answer files). Served on demand to
|
/// Preseed / Autoinstall / Windows answer files). Served on demand to
|
||||||
/// booting clients with per-host hostname/IP/MAC templating; lives in
|
/// booting clients with per-host hostname/IP/MAC templating; lives in
|
||||||
@@ -89,6 +116,10 @@ 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.
|
||||||
|
|||||||
+121
-10
@@ -96,6 +96,8 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
|
|||||||
"share" | "smb-share" => smb_share_command(state, tail).await,
|
"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,
|
"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(" ")),
|
||||||
@@ -118,17 +120,21 @@ fn status_text(s: &AppState) -> String {
|
|||||||
// v0.4.67: NFSv3 sources too.
|
// v0.4.67: NFSv3 sources too.
|
||||||
let nfs_shares = s.nfs_shares.list();
|
let nfs_shares = s.nfs_shares.list();
|
||||||
let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count();
|
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})\n\
|
isos: {n_isos} (local: {n_local}, smb: {n_smb}, nfs: {n_nfs}, sftp: {n_sftp})\n\
|
||||||
clients: {n_clients}\n\
|
clients: {n_clients}\n\
|
||||||
queue: {n_entries}\n\
|
queue: {n_entries}\n\
|
||||||
smb server: {smb}\n\
|
smb server: {smb}\n\
|
||||||
smb shares: {n_smb_total} configured ({n_smb_active} reachable)\n\
|
smb shares: {n_smb_total} configured ({n_smb_active} reachable)\n\
|
||||||
nfs shares: {n_nfs_total} configured ({n_nfs_active} reachable)\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() {
|
||||||
@@ -150,6 +156,10 @@ fn status_text(s: &AppState) -> String {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Nfs { .. }))
|
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Nfs { .. }))
|
||||||
.count(),
|
.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 = queue_entries.len(),
|
||||||
smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")),
|
smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")),
|
||||||
@@ -157,6 +167,8 @@ fn status_text(s: &AppState) -> String {
|
|||||||
n_smb_active = smb_reachable,
|
n_smb_active = smb_reachable,
|
||||||
n_nfs_total = nfs_shares.len(),
|
n_nfs_total = nfs_shares.len(),
|
||||||
n_nfs_active = nfs_reachable,
|
n_nfs_active = nfs_reachable,
|
||||||
|
n_sftp_total = sftp_shares.len(),
|
||||||
|
n_sftp_active = sftp_reachable,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +189,8 @@ fn isos_text(s: &AppState) -> String {
|
|||||||
openpxe_iso_store::IsoSource::Smb { share_id, .. } => format!("smb:{share_id}"),
|
openpxe_iso_store::IsoSource::Smb { share_id, .. } => format!("smb:{share_id}"),
|
||||||
// v0.4.67: NFSv3 via in-process nfs3_client.
|
// v0.4.67: NFSv3 via in-process nfs3_client.
|
||||||
openpxe_iso_store::IsoSource::Nfs { share_id, .. } => format!("nfs:{share_id}"),
|
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,
|
||||||
@@ -321,9 +335,7 @@ async fn smb_share_command(s: &AppState, args: &[String]) -> Result<String, Stri
|
|||||||
}
|
}
|
||||||
Some("add") => {
|
Some("add") => {
|
||||||
// share add //server/share [guest|user:password]
|
// share add //server/share [guest|user:password]
|
||||||
let target = args
|
let target = args.get(1).ok_or_else(|| {
|
||||||
.get(1)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
"usage: share add //server/share [guest|user:password]".to_string()
|
"usage: share add //server/share [guest|user:password]".to_string()
|
||||||
})?;
|
})?;
|
||||||
// Accept either `//server/share` (UNC-style) or
|
// Accept either `//server/share` (UNC-style) or
|
||||||
@@ -401,11 +413,7 @@ async fn nfs_share_command(s: &AppState, args: &[String]) -> Result<String, Stri
|
|||||||
return Ok("(no NFS shares configured)".into());
|
return Ok("(no NFS shares configured)".into());
|
||||||
}
|
}
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
let _ = writeln!(
|
let _ = writeln!(out, "{:<24} {:<7} {:<6} TARGET", "ID", "STATUS", "ISOS");
|
||||||
out,
|
|
||||||
"{:<24} {:<7} {:<6} TARGET",
|
|
||||||
"ID", "STATUS", "ISOS"
|
|
||||||
);
|
|
||||||
for m in shares {
|
for m in shares {
|
||||||
let status = if m.reachable { "ok" } else { "down" };
|
let status = if m.reachable { "ok" } else { "down" };
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
@@ -476,6 +484,104 @@ async fn nfs_share_command(s: &AppState, args: &[String]) -> Result<String, Stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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]"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── smb ────────────────────────────────────────────────────────────────
|
// ── smb ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[allow(clippy::unused_async)]
|
#[allow(clippy::unused_async)]
|
||||||
@@ -622,6 +728,11 @@ OpenPXE terminal — available commands:
|
|||||||
nfs remove <id> forget an NFS share
|
nfs remove <id> forget an NFS share
|
||||||
nfs scan <id> re-list an NFS share for new ISOs
|
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 status outbound Samba state (Windows install media)
|
||||||
smb start | stop | reload control the outbound smbd
|
smb start | stop | reload control the outbound smbd
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use openpxe_core::{Error, Result};
|
use openpxe_core::{Error, Result};
|
||||||
use openpxe_iso_store::{IsoMeta, IsoStore, UploadHandle};
|
use openpxe_iso_store::{IsoMeta, IsoStore, UploadHandle};
|
||||||
|
use parking_lot::RwLock;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -19,7 +20,11 @@ const DEFAULT_CHUNK_SIZE: u64 = 8 * 1024 * 1024;
|
|||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
pub struct UploadSessions {
|
pub struct UploadSessions {
|
||||||
inner: Arc<Mutex<HashMap<String, Arc<Mutex<UploadSession>>>>>,
|
// 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 {
|
struct UploadSession {
|
||||||
@@ -67,8 +72,7 @@ impl UploadSessions {
|
|||||||
};
|
};
|
||||||
|
|
||||||
self.inner
|
self.inner
|
||||||
.lock()
|
.write()
|
||||||
.await
|
|
||||||
.insert(upload_id.clone(), Arc::new(Mutex::new(session)));
|
.insert(upload_id.clone(), Arc::new(Mutex::new(session)));
|
||||||
|
|
||||||
Ok(UploadStarted {
|
Ok(UploadStarted {
|
||||||
@@ -88,7 +92,7 @@ impl UploadSessions {
|
|||||||
chunk: Bytes,
|
chunk: Bytes,
|
||||||
complete: bool,
|
complete: bool,
|
||||||
) -> Result<UploadAppend> {
|
) -> Result<UploadAppend> {
|
||||||
let Some(session_lock) = self.inner.lock().await.get(upload_id).cloned() else {
|
let Some(session_lock) = self.inner.read().get(upload_id).cloned() else {
|
||||||
return Err(Error::Invalid(format!("no such upload '{upload_id}'")));
|
return Err(Error::Invalid(format!("no such upload '{upload_id}'")));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -119,7 +123,7 @@ impl UploadSessions {
|
|||||||
if let Err(e) = handle.write_chunk(&chunk).await {
|
if let Err(e) = handle.write_chunk(&chunk).await {
|
||||||
let handle = session.handle.take();
|
let handle = session.handle.take();
|
||||||
drop(session);
|
drop(session);
|
||||||
self.inner.lock().await.remove(upload_id);
|
self.inner.write().remove(upload_id);
|
||||||
if let Some(handle) = handle {
|
if let Some(handle) = handle {
|
||||||
let _ = handle.abort().await;
|
let _ = handle.abort().await;
|
||||||
}
|
}
|
||||||
@@ -156,11 +160,11 @@ impl UploadSessions {
|
|||||||
let meta = match handle.finish(store).await {
|
let meta = match handle.finish(store).await {
|
||||||
Ok(meta) => meta,
|
Ok(meta) => meta,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.inner.lock().await.remove(upload_id);
|
self.inner.write().remove(upload_id);
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
self.inner.lock().await.remove(upload_id);
|
self.inner.write().remove(upload_id);
|
||||||
Ok(UploadAppend::Complete {
|
Ok(UploadAppend::Complete {
|
||||||
offset: new_offset,
|
offset: new_offset,
|
||||||
iso: Box::new(meta),
|
iso: Box::new(meta),
|
||||||
@@ -168,7 +172,7 @@ impl UploadSessions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn abort(&self, upload_id: &str) -> Result<()> {
|
pub async fn abort(&self, upload_id: &str) -> Result<()> {
|
||||||
let Some(session_lock) = self.inner.lock().await.remove(upload_id) else {
|
let Some(session_lock) = self.inner.write().remove(upload_id) else {
|
||||||
return Err(Error::Invalid(format!("no such upload '{upload_id}'")));
|
return Err(Error::Invalid(format!("no such upload '{upload_id}'")));
|
||||||
};
|
};
|
||||||
let mut session = session_lock.lock().await;
|
let mut session = session_lock.lock().await;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use axum::body::Body;
|
|||||||
use axum::http::{header, Request, StatusCode};
|
use axum::http::{header, Request, StatusCode};
|
||||||
use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore};
|
use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore};
|
||||||
use openpxe_http_api::{build_router, AppState};
|
use openpxe_http_api::{build_router, AppState};
|
||||||
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbShareManager};
|
use openpxe_iso_store::{IsoStore, NfsShareManager, SftpShareManager, SmbShareManager};
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
||||||
@@ -96,6 +96,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
|||||||
let settings = SettingsStore::load_or_default(dir.path());
|
let settings = SettingsStore::load_or_default(dir.path());
|
||||||
let smb_shares = SmbShareManager::new(dir.path(), iso_store.clone());
|
let smb_shares = SmbShareManager::new(dir.path(), iso_store.clone());
|
||||||
let nfs_shares = NfsShareManager::new(dir.path(), iso_store.clone());
|
let nfs_shares = NfsShareManager::new(dir.path(), iso_store.clone());
|
||||||
|
let sftp_shares = SftpShareManager::new(dir.path(), iso_store.clone());
|
||||||
let unattended = openpxe_iso_store::UnattendedStore::new(dir.path().join("unattended"));
|
let unattended = openpxe_iso_store::UnattendedStore::new(dir.path().join("unattended"));
|
||||||
unattended.ensure_dir().await.unwrap();
|
unattended.ensure_dir().await.unwrap();
|
||||||
let log_bus = LogBus::new(64);
|
let log_bus = LogBus::new(64);
|
||||||
@@ -114,7 +115,10 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
|||||||
settings,
|
settings,
|
||||||
hosts,
|
hosts,
|
||||||
boot_log,
|
boot_log,
|
||||||
|
boot_rules: openpxe_core::BootRulesStore::load_or_default(dir.path()),
|
||||||
|
boot_tokens: openpxe_core::BootTokens::new(),
|
||||||
branding,
|
branding,
|
||||||
|
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
|
||||||
admin,
|
admin,
|
||||||
sessions,
|
sessions,
|
||||||
sso,
|
sso,
|
||||||
@@ -124,12 +128,14 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
|||||||
smb: None,
|
smb: None,
|
||||||
smb_shares,
|
smb_shares,
|
||||||
nfs_shares,
|
nfs_shares,
|
||||||
|
sftp_shares,
|
||||||
unattended,
|
unattended,
|
||||||
uploads: openpxe_http_api::uploads::UploadSessions::default(),
|
uploads: openpxe_http_api::uploads::UploadSessions::default(),
|
||||||
log_bus,
|
log_bus,
|
||||||
started_at: time::OffsetDateTime::now_utc(),
|
started_at: time::OffsetDateTime::now_utc(),
|
||||||
public_base_url: "http://127.0.0.1".into(),
|
public_base_url: "http://127.0.0.1".into(),
|
||||||
nic_name: "lo".into(),
|
nic_name: "lo".into(),
|
||||||
|
nic_link: String::new(),
|
||||||
subnet_mask: "255.0.0.0".into(),
|
subnet_mask: "255.0.0.0".into(),
|
||||||
gateway: "127.0.0.1".into(),
|
gateway: "127.0.0.1".into(),
|
||||||
};
|
};
|
||||||
@@ -691,7 +697,7 @@ async fn log_recent_returns_buffered_lines() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
|
async fn windows_iso_renders_clean_sanboot_script_with_no_trust_store_writes() {
|
||||||
// Synthesize an ISO with a Windows volume label + the sources/boot.wim
|
// Synthesize an ISO with a Windows volume label + the sources/boot.wim
|
||||||
// sentinel so introspection labels it WindowsPe with has_boot_wim.
|
// sentinel so introspection labels it WindowsPe with has_boot_wim.
|
||||||
let mut buf = vec![0u8; 32 * 2048];
|
let mut buf = vec![0u8; 32 * 2048];
|
||||||
@@ -761,38 +767,35 @@ async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
|
|||||||
"introspection should detect sources/boot.wim sentinel"
|
"introspection should detect sources/boot.wim sentinel"
|
||||||
);
|
);
|
||||||
|
|
||||||
// The boot entry should be a wimboot kind with the canonical 5-file
|
// v0.5.8: Windows boots via iPXE HTTP sanboot of the raw ISO — no SMB,
|
||||||
// chain documented in the LinusTechTips iPXE-Windows guide.
|
// no extraction, no in-ISO file serving, no operator toggle. The boot
|
||||||
|
// entry is a `san_boot_iso` kind pointing at the raw image.
|
||||||
let entry = &meta["boot_entries"][0];
|
let entry = &meta["boot_entries"][0];
|
||||||
assert_eq!(entry["kind"]["kind"], "wimboot");
|
assert_eq!(entry["kind"]["kind"], "san_boot_iso");
|
||||||
let files = entry["kind"]["files"].as_array().unwrap();
|
let iso_url = entry["kind"]["iso_url"].as_str().unwrap();
|
||||||
let names: Vec<&str> = files.iter().map(|f| f[0].as_str().unwrap()).collect();
|
assert!(
|
||||||
assert!(names.contains(&"bootmgr"));
|
std::path::Path::new(iso_url)
|
||||||
assert!(names.contains(&"bootmgr.efi"));
|
.extension()
|
||||||
assert!(names.contains(&"bcd"));
|
.is_some_and(|e| e.eq_ignore_ascii_case("iso")),
|
||||||
assert!(names.contains(&"boot.sdi"));
|
"sanboot should target the raw ISO, got: {iso_url}"
|
||||||
assert!(names.contains(&"boot.wim"));
|
);
|
||||||
|
|
||||||
// Render the entry script and verify:
|
// Render the entry script and verify:
|
||||||
// 1. It uses wimboot
|
// 1. It uses `sanboot` against the raw ISO over HTTP
|
||||||
// 2. All 5 files are referenced via `initrd --name`
|
// 2. NO trust-store / driver / testsigning operations slip in
|
||||||
// 3. NO trust-store / driver / testsigning operations slip in
|
|
||||||
let entry_id = entry["id"].as_str().unwrap();
|
let entry_id = entry["id"].as_str().unwrap();
|
||||||
let url = format!("/boot/{entry_id}.ipxe");
|
let url = format!("/boot/{entry_id}.ipxe");
|
||||||
let (s, body) = get(&app, &url).await;
|
let (s, body) = get(&app, &url).await;
|
||||||
assert_eq!(s, StatusCode::OK);
|
assert_eq!(s, StatusCode::OK);
|
||||||
let script = String::from_utf8(body).unwrap();
|
let script = String::from_utf8(body).unwrap();
|
||||||
assert!(script.contains("kernel "), "missing kernel line:\n{script}");
|
|
||||||
assert!(
|
assert!(
|
||||||
script.contains("ipxe/wimboot"),
|
script.contains("sanboot"),
|
||||||
"missing wimboot loader:\n{script}"
|
"missing sanboot line:\n{script}"
|
||||||
);
|
);
|
||||||
for tag in ["bootmgr", "bootmgr.efi", "bcd", "boot.sdi", "boot.wim"] {
|
|
||||||
assert!(
|
assert!(
|
||||||
script.contains(&format!("initrd --name {tag}")),
|
script.contains(&format!("/{iso_url}")),
|
||||||
"missing `initrd --name {tag}` line:\n{script}"
|
"sanboot should reference the raw ISO url:\n{script}"
|
||||||
);
|
);
|
||||||
}
|
|
||||||
// Hard guarantees we never want to see in any client-facing script.
|
// Hard guarantees we never want to see in any client-facing script.
|
||||||
let lower = script.to_lowercase();
|
let lower = script.to_lowercase();
|
||||||
for forbidden in [
|
for forbidden in [
|
||||||
@@ -1489,9 +1492,10 @@ async fn api_docs_lists_known_endpoints() {
|
|||||||
}
|
}
|
||||||
for needle in [
|
for needle in [
|
||||||
"/api/isos",
|
"/api/isos",
|
||||||
"/api/isos/:id/category",
|
// v0.6.3: docs use axum 0.8's `{param}` capture syntax.
|
||||||
|
"/api/isos/{id}/category",
|
||||||
"/api/storage/disk",
|
"/api/storage/disk",
|
||||||
"/api/branding/logo/:slot",
|
"/api/branding/logo/{slot}",
|
||||||
"/api/unattended",
|
"/api/unattended",
|
||||||
"/api/boot-log",
|
"/api/boot-log",
|
||||||
"/metrics",
|
"/metrics",
|
||||||
@@ -1536,6 +1540,60 @@ async fn status_exposes_custom_logo_flag() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v0.5.4 guard: the typed `StatusResponse` must keep every key the WebUI
|
||||||
|
/// (`crates/webui/src/app.js`) reads off `/api/status`. If a refactor drops
|
||||||
|
/// or renames one, the dashboard silently breaks — this catches it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn status_contract_has_all_ui_keys() {
|
||||||
|
let (state, _dir) = build_state().await;
|
||||||
|
let app = build_router(state);
|
||||||
|
let (s, body) = get(&app, "/api/status").await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
for key in [
|
||||||
|
"version",
|
||||||
|
"public_base_url",
|
||||||
|
"iso_count",
|
||||||
|
"client_count",
|
||||||
|
"queue_count",
|
||||||
|
"imaging_count",
|
||||||
|
"waiting_count",
|
||||||
|
"ipxe_assets",
|
||||||
|
"settings",
|
||||||
|
"smb_share_count",
|
||||||
|
"smb_share_reachable",
|
||||||
|
"nfs_share_count",
|
||||||
|
"nfs_share_reachable",
|
||||||
|
"host_bindings",
|
||||||
|
"custom_logo",
|
||||||
|
"branding",
|
||||||
|
"unattended_count",
|
||||||
|
"uptime_secs",
|
||||||
|
"started_at",
|
||||||
|
"nic_name",
|
||||||
|
"subnet_mask",
|
||||||
|
"gateway",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
v.get(key).is_some(),
|
||||||
|
"/api/status missing UI key '{key}': {v}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Nested branding presence the Settings tab reads.
|
||||||
|
for key in ["light", "dark", "client", "rev"] {
|
||||||
|
assert!(
|
||||||
|
v["branding"].get(key).is_some(),
|
||||||
|
"/api/status branding missing '{key}': {v}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// started_at must remain an RFC3339 string (the UI does fmtUptime on
|
||||||
|
// uptime_secs but renders started_at as text), not a serialized struct.
|
||||||
|
assert!(
|
||||||
|
v["started_at"].is_string(),
|
||||||
|
"started_at should serialize as a string: {v}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async fn put_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec<u8>) {
|
async fn put_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec<u8>) {
|
||||||
let res = router
|
let res = router
|
||||||
.clone()
|
.clone()
|
||||||
@@ -2555,3 +2613,162 @@ async fn acs_garbage_is_rejected_without_500() {
|
|||||||
assert!(location(&resp).contains("sso_error"));
|
assert!(location(&resp).contains("sso_error"));
|
||||||
assert!(!has_session_cookie(&resp));
|
assert!(!has_session_cookie(&resp));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── v0.7.0: tokenized answer files + boot rules ────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unattended_requires_token_once_admin_exists() {
|
||||||
|
let (state, _dir) = build_state().await;
|
||||||
|
let app = build_router(state.clone());
|
||||||
|
// Upload an answer file while in setup mode (everything open).
|
||||||
|
let (ct, body) =
|
||||||
|
multipart_iso_body("ks.ks", b"install\nrootpw s3cret\n%packages\n@core\n%end\n");
|
||||||
|
let (s, b) = post_multipart(&app, "/api/unattended", &ct, body).await;
|
||||||
|
assert_eq!(s, StatusCode::CREATED);
|
||||||
|
let id = serde_json::from_slice::<serde_json::Value>(&b).unwrap()["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
// Pre-setup, the file serves openly (bootstrap parity with the
|
||||||
|
// auth middleware).
|
||||||
|
let (s, _) = get(&app, &format!("/unattended/{id}")).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
|
||||||
|
// Create the admin → the gate arms.
|
||||||
|
let (s, _, cookies) = post_collect(
|
||||||
|
&app,
|
||||||
|
"/api/setup",
|
||||||
|
r#"{"username":"admin","password":"hunter2hunter2"}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::CREATED);
|
||||||
|
let session = session_value(&cookies).unwrap();
|
||||||
|
|
||||||
|
// Bare fetch (the CVE-2026-0386 harvesting pattern) is refused.
|
||||||
|
let (s, _) = get(&app, &format!("/unattended/{id}")).await;
|
||||||
|
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||||||
|
// Garbage token is refused.
|
||||||
|
let (s, _) = get(&app, &format!("/unattended/{id}?t=bogus")).await;
|
||||||
|
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||||||
|
// A token minted for a *different* file is refused.
|
||||||
|
let other = state.boot_tokens.mint("some-other-file");
|
||||||
|
let (s, _) = get(&app, &format!("/unattended/{id}?t={other}")).await;
|
||||||
|
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||||||
|
// The boot-scoped token OpenPXE mints into generated URLs passes.
|
||||||
|
let tok = state.boot_tokens.mint(&id);
|
||||||
|
let (s, b) = get(&app, &format!("/unattended/{id}?t={tok}")).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
assert!(String::from_utf8_lossy(&b).contains("rootpw"));
|
||||||
|
// A logged-in operator (browser testing) passes too.
|
||||||
|
let (s, _) = get_with_cookie(&app, &format!("/unattended/{id}"), &session).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn boot_script_for_pinned_unattended_carries_live_token() {
|
||||||
|
let (state, _dir) = build_state().await;
|
||||||
|
let app = build_router(state.clone());
|
||||||
|
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
|
||||||
|
let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await;
|
||||||
|
assert_eq!(s, StatusCode::CREATED);
|
||||||
|
let (ct, body) = multipart_iso_body("ks.ks", b"install\n%packages\n@core\n%end\n");
|
||||||
|
let (s, b) = post_multipart(&app, "/api/unattended", &ct, body).await;
|
||||||
|
assert_eq!(s, StatusCode::CREATED);
|
||||||
|
let ks_id = serde_json::from_slice::<serde_json::Value>(&b).unwrap()["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
let mac = "aa:bb:cc:dd:ee:71";
|
||||||
|
let pin =
|
||||||
|
format!(r#"{{"mac":"{mac}","target":"fake-alpine-linux","unattended_file":"{ks_id}"}}"#);
|
||||||
|
let (s, _) = post_json(&app, "/api/hosts", &pin).await;
|
||||||
|
assert_eq!(s, StatusCode::CREATED);
|
||||||
|
let (s, b) = get(&app, &format!("/boot/fake-alpine-linux.ipxe?mac={mac}")).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
let script = String::from_utf8_lossy(&b).into_owned();
|
||||||
|
// The injected inst.ks URL ends with a token that is live for the file.
|
||||||
|
let tok = script
|
||||||
|
.split("t=")
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|rest| rest.split_whitespace().next())
|
||||||
|
.expect("kernel arg should carry t=<token>");
|
||||||
|
assert!(
|
||||||
|
state.boot_tokens.check(tok, &ks_id),
|
||||||
|
"token in boot script must be live:\n{script}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn boot_rules_match_and_persist_via_api() {
|
||||||
|
let (state, _dir) = build_state().await;
|
||||||
|
let app = build_router(state);
|
||||||
|
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
|
||||||
|
let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await;
|
||||||
|
assert_eq!(s, StatusCode::CREATED);
|
||||||
|
|
||||||
|
// Save a rule: any MAC under aa:bb:cc, any arch → the Linux entry.
|
||||||
|
let cfg = r#"{"rules":[{"mac_prefix":"AA-BB-CC","arch":"","target":"fake-alpine-linux","enabled":true,"note":"rack"}],"webhook_url":""}"#;
|
||||||
|
let (s, _) = put_json(&app, "/api/boot-rules", cfg).await;
|
||||||
|
assert_eq!(s, StatusCode::NO_CONTENT);
|
||||||
|
// The config reads back (prefix normalized to colons).
|
||||||
|
let (s, b) = get(&app, "/api/boot-rules").await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||||||
|
assert_eq!(v["rules"][0]["mac_prefix"], "aa:bb:cc");
|
||||||
|
|
||||||
|
// A matching client short-circuits to the target...
|
||||||
|
let (s, b) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:09&arch=uefi-x64").await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
let script = String::from_utf8_lossy(&b);
|
||||||
|
assert!(
|
||||||
|
script.contains("boot rule -> fake-alpine-linux"),
|
||||||
|
"rule did not chain:\n{script}"
|
||||||
|
);
|
||||||
|
// ...while a non-matching one still gets the menu.
|
||||||
|
let (s, b) = get(&app, "/boot.ipxe?mac=11:22:33:00:00:09&arch=uefi-x64").await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
assert!(
|
||||||
|
String::from_utf8_lossy(&b).contains("menu"),
|
||||||
|
"non-matching client should see the menu"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn arch_selective_rule_ignores_other_arches() {
|
||||||
|
let (state, _dir) = build_state().await;
|
||||||
|
let app = build_router(state);
|
||||||
|
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
|
||||||
|
let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await;
|
||||||
|
assert_eq!(s, StatusCode::CREATED);
|
||||||
|
let cfg = r#"{"rules":[{"mac_prefix":"","arch":"uefi-arm64","target":"fake-alpine-linux","enabled":true,"note":""}],"webhook_url":""}"#;
|
||||||
|
let (s, _) = put_json(&app, "/api/boot-rules", cfg).await;
|
||||||
|
assert_eq!(s, StatusCode::NO_CONTENT);
|
||||||
|
// x64 client: no match → menu.
|
||||||
|
let (s, b) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:01&arch=uefi-x64").await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
assert!(!String::from_utf8_lossy(&b).contains("boot rule ->"));
|
||||||
|
// arm64 client: match.
|
||||||
|
let (s, b) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:01&arch=uefi-arm64").await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
assert!(String::from_utf8_lossy(&b).contains("boot rule -> fake-alpine-linux"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn boot_rule_driver_mode_pin_round_trips_via_api() {
|
||||||
|
// v0.7.1: a rule may pin only a boot binary (no target) — the API
|
||||||
|
// must persist and return it for the DHCP proxy to consult.
|
||||||
|
let (state, _dir) = build_state().await;
|
||||||
|
let app = build_router(state.clone());
|
||||||
|
let cfg = r#"{"rules":[{"mac_prefix":"aa:bb:cc","arch":"","target":"","driver_mode":"shim","enabled":true,"note":"SB rack"}],"webhook_url":""}"#;
|
||||||
|
let (s, _) = put_json(&app, "/api/boot-rules", cfg).await;
|
||||||
|
assert_eq!(s, StatusCode::NO_CONTENT);
|
||||||
|
let (s, b) = get(&app, "/api/boot-rules").await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||||||
|
assert_eq!(v["rules"][0]["driver_mode"], "shim");
|
||||||
|
// And the store the DHCP proxy shares resolves the pin.
|
||||||
|
assert_eq!(
|
||||||
|
state.boot_rules.driver_mode_hint("aa:bb:cc:00:00:07", None),
|
||||||
|
Some(openpxe_core::DriverMode::Shim)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,43 +6,40 @@
|
|||||||
//! 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`):
|
//! Filename convention (matches `ClientArch::ipxe_bootfile_mode`):
|
||||||
|
//!
|
||||||
|
//! 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;
|
use openpxe_core::{ClientArch, DriverMode};
|
||||||
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 the embedded iPXE binary for `arch`, or `None` if we didn't bundle
|
/// Return a named embedded asset (e.g. `snponly.efi`, `wimboot`) as a
|
||||||
/// one for that architecture.
|
/// `Cow` over the embedded bytes. In release builds the data is borrowed
|
||||||
#[must_use]
|
/// straight from the binary's rodata — **zero copy** — which matters
|
||||||
pub fn bootfile_bytes(arch: ClientArch) -> Option<Vec<u8>> {
|
/// because the TFTP and HTTP serving paths hit this for every boot
|
||||||
let name = arch.ipxe_bootfile()?;
|
/// (`ipxe.efi` is ~1 MiB). Debug builds read from disk and return Owned.
|
||||||
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)
|
||||||
@@ -56,25 +53,48 @@ pub fn list_assets() -> Vec<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log at startup which iPXE binaries are present and which are missing.
|
/// Log at startup which iPXE binaries are present and which are missing, for
|
||||||
|
/// 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 needed = [
|
let arches = [
|
||||||
(ClientArch::LegacyX86, "undionly.kpxe"),
|
ClientArch::LegacyX86,
|
||||||
(ClientArch::Ia32Uefi, "snponly-i386.efi"),
|
ClientArch::Ia32Uefi,
|
||||||
(ClientArch::X64Uefi, "snponly.efi"),
|
ClientArch::X64Uefi,
|
||||||
// ARM32 UEFI deferred — no upstream snponly binary published.
|
// ARM32 UEFI deferred — no upstream binary published in either mode.
|
||||||
(ClientArch::Arm64Uefi, "snponly-arm64.efi"),
|
ClientArch::Arm64Uefi,
|
||||||
];
|
];
|
||||||
for (arch, name) in needed {
|
for arch in arches {
|
||||||
|
for mode in [DriverMode::Firmware, DriverMode::Builtin, DriverMode::Shim] {
|
||||||
|
let Some(name) = arch.ipxe_bootfile_mode(mode) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
if have.contains(name) {
|
if have.contains(name) {
|
||||||
tracing::info!(target: "openpxe::ipxe", "bundled iPXE for {}: {}", arch.as_str(), name);
|
tracing::info!(
|
||||||
} else {
|
target: "openpxe::ipxe",
|
||||||
|
"bundled iPXE for {} [{mode:?}]: {name}", arch.as_str()
|
||||||
|
);
|
||||||
|
} else if mode == DriverMode::Firmware {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "openpxe::ipxe",
|
target: "openpxe::ipxe",
|
||||||
"MISSING iPXE binary for {}: {} — clients of this arch will not PXE boot",
|
"MISSING iPXE binary for {} [{mode:?}]: {name} — clients of this arch will not PXE boot",
|
||||||
arch.as_str(), name
|
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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg",
|
|||||||
# kernel mount. See crates/iso-store/src/nfs_share.rs for usage.
|
# kernel mount. See crates/iso-store/src/nfs_share.rs for usage.
|
||||||
nfs3_client = { workspace = true }
|
nfs3_client = { workspace = true }
|
||||||
nfs3_types = { 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
|
# Needed for the Stream trait that wraps the mpsc receiver feeding
|
||||||
# NFS read-loop bytes into axum's Body::from_stream.
|
# NFS read-loop bytes into axum's Body::from_stream.
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ pub enum BootKind {
|
|||||||
wimboot_url: String,
|
wimboot_url: String,
|
||||||
files: Vec<(String, String)>,
|
files: Vec<(String, String)>,
|
||||||
},
|
},
|
||||||
/// Last-resort: SAN-boot the ISO as an emulated CD. Only works for small
|
/// SAN-boot the raw ISO as an emulated CD (iPXE `sanboot`). The emulated
|
||||||
/// ISOs (<~1 GiB) and older distros. Kept for completeness, not the
|
/// CD is backed by on-demand HTTP range reads, so ISO size is *not* a
|
||||||
/// default.
|
/// constraint — this is the primary path for Windows (v0.5.8) and for any
|
||||||
|
/// 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 },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum DistroFamily {
|
pub enum DistroFamily {
|
||||||
DebianUbuntu,
|
DebianUbuntu,
|
||||||
@@ -22,10 +22,22 @@ pub enum DistroFamily {
|
|||||||
Arch,
|
Arch,
|
||||||
Alpine,
|
Alpine,
|
||||||
WindowsPe,
|
WindowsPe,
|
||||||
|
#[default]
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
/// Bumped whenever the introspection logic changes in a way that should
|
||||||
|
/// 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>,
|
||||||
@@ -35,17 +47,28 @@ 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 {
|
||||||
family: DistroFamily::Unknown,
|
introspect_rev: INTROSPECT_REV,
|
||||||
volume_label: None,
|
..Default::default()
|
||||||
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 {
|
||||||
@@ -68,6 +91,11 @@ 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.
|
||||||
@@ -75,7 +103,13 @@ 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;
|
||||||
let mut haystack = Vec::with_capacity(scan_bytes.min(32 * 1024 * 1024));
|
// Size the haystack to what will actually be read — the scan cap or
|
||||||
|
// 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 {
|
||||||
@@ -85,12 +119,37 @@ pub fn introspect(path: &Path) -> IntrospectionReport {
|
|||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,6 +217,83 @@ fn contains_ascii(haystack: &[u8], needle: &[u8]) -> bool {
|
|||||||
.any(|w| w.eq_ignore_ascii_case(needle))
|
.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)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -179,4 +315,72 @@ mod tests {
|
|||||||
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"
|
||||||
|
)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub mod entry;
|
|||||||
pub mod introspect;
|
pub mod introspect;
|
||||||
pub mod nfs_share;
|
pub mod nfs_share;
|
||||||
pub mod pxe_logo;
|
pub mod pxe_logo;
|
||||||
|
pub mod sftp_share;
|
||||||
pub mod smb;
|
pub mod smb;
|
||||||
pub mod smb_share;
|
pub mod smb_share;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
@@ -40,6 +41,13 @@ pub use smb_share::{SmbAddRequest, SmbShare, SmbShareError, SmbShareManager, Smb
|
|||||||
// "works in any container" property as SMB, plus support for HTTP
|
// "works in any container" property as SMB, plus support for HTTP
|
||||||
// Range requests because NFSv3 READ3 takes an explicit offset.
|
// Range requests because NFSv3 READ3 takes an explicit offset.
|
||||||
pub use nfs_share::{NfsAddRequest, NfsShare, NfsShareError, NfsShareManager, NfsStream};
|
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::{
|
pub use store::{
|
||||||
generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore, UploadHandle,
|
generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore, UploadHandle,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -57,14 +57,14 @@
|
|||||||
//! UI to ask for. (If a future server needs Kerberos or non-default
|
//! UI to ask for. (If a future server needs Kerberos or non-default
|
||||||
//! uid mapping we can add those, but for ISO read access nobody does.)
|
//! uid mapping we can add those, but for ISO read access nobody does.)
|
||||||
|
|
||||||
use crate::introspect::{DistroFamily, IntrospectionReport};
|
use crate::introspect::IntrospectionReport;
|
||||||
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use nfs3_client::tokio::TokioConnector;
|
use nfs3_client::tokio::TokioConnector;
|
||||||
use nfs3_client::Nfs3ConnectionBuilder;
|
use nfs3_client::Nfs3ConnectionBuilder;
|
||||||
use nfs3_types::nfs3::{
|
use nfs3_types::nfs3::{
|
||||||
self as nfs3, diropargs3, entry3, filename3, nfs_fh3, GETATTR3args, LOOKUP3args,
|
self as nfs3, diropargs3, entry3, filename3, nfs_fh3, GETATTR3args, LOOKUP3args, Nfs3Result,
|
||||||
Nfs3Result, READ3args, READDIR3args,
|
READ3args, READDIR3args,
|
||||||
};
|
};
|
||||||
use nfs3_types::rpc::{auth_unix, opaque_auth};
|
use nfs3_types::rpc::{auth_unix, opaque_auth};
|
||||||
use nfs3_types::xdr_codec::Opaque;
|
use nfs3_types::xdr_codec::Opaque;
|
||||||
@@ -220,10 +220,7 @@ impl NfsShareManager {
|
|||||||
/// Register an NFS share. Validates, probes connectivity by
|
/// Register an NFS share. Validates, probes connectivity by
|
||||||
/// performing a real MOUNT3 + READDIR3, and registers the
|
/// performing a real MOUNT3 + READDIR3, and registers the
|
||||||
/// resulting ISOs with the store.
|
/// resulting ISOs with the store.
|
||||||
pub async fn add(
|
pub async fn add(&self, req: NfsAddRequest) -> std::result::Result<NfsShare, NfsShareError> {
|
||||||
&self,
|
|
||||||
req: NfsAddRequest,
|
|
||||||
) -> std::result::Result<NfsShare, NfsShareError> {
|
|
||||||
let server = normalize_server(&req.server);
|
let server = normalize_server(&req.server);
|
||||||
let export = req.export.trim().to_string();
|
let export = req.export.trim().to_string();
|
||||||
if server.is_empty() {
|
if server.is_empty() {
|
||||||
@@ -258,7 +255,9 @@ impl NfsShareManager {
|
|||||||
if let Err(e) = self.rescan_inner(&id).await {
|
if let Err(e) = self.rescan_inner(&id).await {
|
||||||
let m = self.get(&id);
|
let m = self.get(&id);
|
||||||
return Err(NfsShareError {
|
return Err(NfsShareError {
|
||||||
error: m.as_ref().and_then(|m| m.last_error.clone())
|
error: m
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.last_error.clone())
|
||||||
.unwrap_or_else(|| e.to_string()),
|
.unwrap_or_else(|| e.to_string()),
|
||||||
stderr: String::new(),
|
stderr: String::new(),
|
||||||
hint: m.and_then(|m| m.last_hint),
|
hint: m.and_then(|m| m.last_hint),
|
||||||
@@ -333,8 +332,7 @@ impl NfsShareManager {
|
|||||||
return Err(Error::Invalid(format!("invalid filename '{filename}'")));
|
return Err(Error::Invalid(format!("invalid filename '{filename}'")));
|
||||||
}
|
}
|
||||||
|
|
||||||
let (tx, rx) =
|
let (tx, rx) = tokio::sync::mpsc::channel::<std::io::Result<Bytes>>(STREAM_BUFFER_DEPTH);
|
||||||
tokio::sync::mpsc::channel::<std::io::Result<Bytes>>(STREAM_BUFFER_DEPTH);
|
|
||||||
let server = share.server.clone();
|
let server = share.server.clone();
|
||||||
let export = share.export.clone();
|
let export = share.export.clone();
|
||||||
let port = share.port;
|
let port = share.port;
|
||||||
@@ -358,16 +356,11 @@ impl NfsShareManager {
|
|||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
// Best-effort signal of the error to the consumer.
|
// Best-effort signal of the error to the consumer.
|
||||||
// If the receiver has already dropped we just exit.
|
// If the receiver has already dropped we just exit.
|
||||||
let _ = tx
|
let _ = tx.send(Err(std::io::Error::other(e.to_string()))).await;
|
||||||
.send(Err(std::io::Error::other(e.to_string())))
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(NfsStream {
|
Ok(NfsStream { rx, _task: task })
|
||||||
rx,
|
|
||||||
_task: task,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── internals ─────────────────────────────────────────────────────
|
// ── internals ─────────────────────────────────────────────────────
|
||||||
@@ -399,13 +392,7 @@ impl NfsShareManager {
|
|||||||
// Same approach as SMB: no real introspection over the
|
// Same approach as SMB: no real introspection over the
|
||||||
// network in v0.4.67. The boot-entry generator falls back
|
// network in v0.4.67. The boot-entry generator falls back
|
||||||
// to filename-based sanboot detection.
|
// to filename-based sanboot detection.
|
||||||
let report = IntrospectionReport {
|
let report = IntrospectionReport::default();
|
||||||
family: DistroFamily::Unknown,
|
|
||||||
volume_label: None,
|
|
||||||
kernel_path: None,
|
|
||||||
initrd_paths: Vec::new(),
|
|
||||||
has_boot_wim: false,
|
|
||||||
};
|
|
||||||
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
||||||
let source = IsoSource::Nfs {
|
let source = IsoSource::Nfs {
|
||||||
share_id: share.id.clone(),
|
share_id: share.id.clone(),
|
||||||
@@ -988,8 +975,14 @@ mod tests {
|
|||||||
// the allow-list and the secure/insecure angle.
|
// the allow-list and the secure/insecure angle.
|
||||||
let h = hint_for("connect failed: MNT3ERR_ACCES").unwrap();
|
let h = hint_for("connect failed: MNT3ERR_ACCES").unwrap();
|
||||||
let lc = h.to_lowercase();
|
let lc = h.to_lowercase();
|
||||||
assert!(lc.contains("insecure") || lc.contains("privileged"), "got: {h}");
|
assert!(
|
||||||
assert!(lc.contains("allow") || lc.contains("permission"), "got: {h}");
|
lc.contains("insecure") || lc.contains("privileged"),
|
||||||
|
"got: {h}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
lc.contains("allow") || lc.contains("permission"),
|
||||||
|
"got: {h}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -56,7 +56,7 @@
|
|||||||
//! streaming. A follow-up release can add libsmbclient-based seek if
|
//! streaming. A follow-up release can add libsmbclient-based seek if
|
||||||
//! a real workload needs it.
|
//! a real workload needs it.
|
||||||
|
|
||||||
use crate::introspect::{DistroFamily, IntrospectionReport};
|
use crate::introspect::IntrospectionReport;
|
||||||
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
|
||||||
use openpxe_core::{Error, Result};
|
use openpxe_core::{Error, Result};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
@@ -231,10 +231,7 @@ impl SmbShareManager {
|
|||||||
|
|
||||||
/// Add or refresh a share. Validates the input, writes a creds
|
/// Add or refresh a share. Validates the input, writes a creds
|
||||||
/// file, probes connectivity, and scans for ISOs.
|
/// file, probes connectivity, and scans for ISOs.
|
||||||
pub async fn add(
|
pub async fn add(&self, req: SmbAddRequest) -> std::result::Result<SmbShare, SmbShareError> {
|
||||||
&self,
|
|
||||||
req: SmbAddRequest,
|
|
||||||
) -> std::result::Result<SmbShare, SmbShareError> {
|
|
||||||
let server = normalize_server(&req.server);
|
let server = normalize_server(&req.server);
|
||||||
let share = req.share.trim().trim_start_matches('/').to_string();
|
let share = req.share.trim().trim_start_matches('/').to_string();
|
||||||
if server.is_empty() {
|
if server.is_empty() {
|
||||||
@@ -309,7 +306,9 @@ impl SmbShareManager {
|
|||||||
if let Err(e) = self.rescan_inner(&id).await {
|
if let Err(e) = self.rescan_inner(&id).await {
|
||||||
let m = self.get(&id);
|
let m = self.get(&id);
|
||||||
return Err(SmbShareError {
|
return Err(SmbShareError {
|
||||||
error: m.as_ref().and_then(|m| m.last_error.clone())
|
error: m
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.last_error.clone())
|
||||||
.unwrap_or_else(|| e.to_string()),
|
.unwrap_or_else(|| e.to_string()),
|
||||||
stderr: String::new(),
|
stderr: String::new(),
|
||||||
hint: m.and_then(|m| m.last_hint),
|
hint: m.and_then(|m| m.last_hint),
|
||||||
@@ -365,11 +364,7 @@ impl SmbShareManager {
|
|||||||
/// throttling concurrent smbclients) would need to await without
|
/// throttling concurrent smbclients) would need to await without
|
||||||
/// changing the call sites.
|
/// changing the call sites.
|
||||||
#[allow(clippy::unused_async)]
|
#[allow(clippy::unused_async)]
|
||||||
pub async fn stream_iso(
|
pub async fn stream_iso(&self, share_id: &str, filename: &str) -> Result<SmbStream> {
|
||||||
&self,
|
|
||||||
share_id: &str,
|
|
||||||
filename: &str,
|
|
||||||
) -> Result<SmbStream> {
|
|
||||||
let share = self
|
let share = self
|
||||||
.get(share_id)
|
.get(share_id)
|
||||||
.ok_or_else(|| Error::Invalid(format!("no such SMB share '{share_id}'")))?;
|
.ok_or_else(|| Error::Invalid(format!("no such SMB share '{share_id}'")))?;
|
||||||
@@ -377,9 +372,7 @@ impl SmbShareManager {
|
|||||||
// share root. smbclient itself accepts only filenames at the
|
// share root. smbclient itself accepts only filenames at the
|
||||||
// share root in our `get` form, but belt-and-suspenders.
|
// share root in our `get` form, but belt-and-suspenders.
|
||||||
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||||
return Err(Error::Invalid(format!(
|
return Err(Error::Invalid(format!("invalid filename '{filename}'")));
|
||||||
"invalid filename '{filename}'"
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
let creds = share
|
let creds = share
|
||||||
.creds_path
|
.creds_path
|
||||||
@@ -470,13 +463,7 @@ impl SmbShareManager {
|
|||||||
// and the operator gets *something* bootable. A follow-up
|
// and the operator gets *something* bootable. A follow-up
|
||||||
// release can do a bounded `smbclient get` of the first
|
// release can do a bounded `smbclient get` of the first
|
||||||
// 64 KiB for real detection.
|
// 64 KiB for real detection.
|
||||||
let report = IntrospectionReport {
|
let report = IntrospectionReport::default();
|
||||||
family: DistroFamily::Unknown,
|
|
||||||
volume_label: None,
|
|
||||||
kernel_path: None,
|
|
||||||
initrd_paths: Vec::new(),
|
|
||||||
has_boot_wim: false,
|
|
||||||
};
|
|
||||||
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
|
||||||
let source = IsoSource::Smb {
|
let source = IsoSource::Smb {
|
||||||
share_id: share.id.clone(),
|
share_id: share.id.clone(),
|
||||||
@@ -543,10 +530,7 @@ impl SmbShareManager {
|
|||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
return Err((
|
return Err((format!("could not exec smbclient: {e}"), stderr));
|
||||||
format!("could not exec smbclient: {e}"),
|
|
||||||
stderr,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
@@ -757,10 +741,7 @@ fn parse_ls_iso(out: &str) -> Vec<SmbListEntry> {
|
|||||||
|
|
||||||
/// Pre-flight TCP probe to `server:port`. Format matches v0.4.64 NFS
|
/// Pre-flight TCP probe to `server:port`. Format matches v0.4.64 NFS
|
||||||
/// probe so the UI banner reads consistently.
|
/// probe so the UI banner reads consistently.
|
||||||
async fn tcp_probe(
|
async fn tcp_probe(server: &str, port: u16) -> std::result::Result<(), (String, String)> {
|
||||||
server: &str,
|
|
||||||
port: u16,
|
|
||||||
) -> std::result::Result<(), (String, String)> {
|
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
let addr = format!("{server}:{port}");
|
let addr = format!("{server}:{port}");
|
||||||
match tokio::time::timeout(PROBE_TIMEOUT, TcpStream::connect(&addr)).await {
|
match tokio::time::timeout(PROBE_TIMEOUT, TcpStream::connect(&addr)).await {
|
||||||
@@ -937,8 +918,8 @@ mod tests {
|
|||||||
// exec error in `error` plus an empty `stderr`. The
|
// exec error in `error` plus an empty `stderr`. The
|
||||||
// SmbShareError constructor's hint_for fallback checks error
|
// SmbShareError constructor's hint_for fallback checks error
|
||||||
// too, so this pattern needs to translate as well.
|
// 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)")
|
let h2 =
|
||||||
.unwrap();
|
hint_for("could not exec smbclient: No such file or directory (os error 2)").unwrap();
|
||||||
assert!(h2.contains("smbclient"));
|
assert!(h2.contains("smbclient"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+152
-33
@@ -24,6 +24,10 @@ use tokio::io::AsyncWriteExt;
|
|||||||
/// `nfs3_client` crate (in-process, no subprocess). Same "works in
|
/// `nfs3_client` crate (in-process, no subprocess). Same "works in
|
||||||
/// any container" property as SMB, plus Range requests work because
|
/// any container" property as SMB, plus Range requests work because
|
||||||
/// NFSv3 READ3 takes an explicit offset.
|
/// 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 {
|
||||||
@@ -42,6 +46,13 @@ pub enum IsoSource {
|
|||||||
/// Filename at the export root.
|
/// Filename at the export root.
|
||||||
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.
|
/// Where the ISO lands in the PXE menu hierarchy.
|
||||||
@@ -212,7 +223,8 @@ 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(meta) = serde_json::from_str::<IsoMeta>(&text) {
|
if let Ok(mut meta) = serde_json::from_str::<IsoMeta>(&text) {
|
||||||
|
self.reintrospect_if_stale(&mut meta).await;
|
||||||
self.insert(meta);
|
self.insert(meta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,6 +232,46 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -290,20 +342,29 @@ impl IsoStore {
|
|||||||
/// SMB sources or when the file is missing.
|
/// SMB sources or when the file is missing.
|
||||||
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same resolution as [`Self::iso_path_for`], but for a meta the
|
||||||
|
/// caller already holds — skips the second registry lock + deep
|
||||||
|
/// clone, which matters on the per-range-request ISO serving path
|
||||||
|
/// (a sanboot install issues hundreds of those).
|
||||||
|
#[must_use]
|
||||||
|
pub fn local_path(&self, meta: &IsoMeta) -> Option<PathBuf> {
|
||||||
match &meta.source {
|
match &meta.source {
|
||||||
IsoSource::Local => {
|
IsoSource::Local => {
|
||||||
let path = self.iso_path(id);
|
let path = self.iso_path(&meta.id);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
Some(path)
|
Some(path)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// SMB and NFS sources have no local path — they're
|
// SMB, NFS, and SFTP sources have no local path — they're
|
||||||
// streamed in-process. Callers must inspect the source
|
// streamed in-process. Callers must inspect the source
|
||||||
// kind first and dispatch to the appropriate share
|
// kind first and dispatch to the appropriate share
|
||||||
// manager.
|
// manager.
|
||||||
IsoSource::Smb { .. } | IsoSource::Nfs { .. } => None,
|
IsoSource::Smb { .. } | IsoSource::Nfs { .. } | IsoSource::Sftp { .. } => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,9 +424,9 @@ impl IsoStore {
|
|||||||
pub fn drop_external_source(&self, share_id: &str) {
|
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| match &m.source {
|
||||||
IsoSource::Smb { share_id: sid, .. } | IsoSource::Nfs { share_id: sid, .. } => {
|
IsoSource::Smb { share_id: sid, .. }
|
||||||
sid != share_id
|
| IsoSource::Nfs { share_id: sid, .. }
|
||||||
}
|
| IsoSource::Sftp { share_id: sid, .. } => sid != share_id,
|
||||||
IsoSource::Local => true,
|
IsoSource::Local => true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -546,22 +607,22 @@ fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> V
|
|||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| filename.to_string());
|
.unwrap_or_else(|| filename.to_string());
|
||||||
match r.family {
|
match r.family {
|
||||||
DistroFamily::WindowsPe if r.has_boot_wim => {
|
DistroFamily::WindowsPe => {
|
||||||
// Standard wimboot chain. Paths are in-ISO; the HTTP layer maps
|
// v0.5.8: boot Windows directly via iPXE HTTP sanboot. iPXE
|
||||||
// `iso/<id>/<path>` to on-disk extraction via ISO9660 lookup.
|
// exposes the raw ISO as an emulated CD backed by on-demand
|
||||||
let base = format!("iso/{id}");
|
// HTTP range reads, and Windows Setup boots from it. This
|
||||||
|
// replaces the old wimboot+SMB chain, which (a) needed an SMB
|
||||||
|
// server the host often can't provide (port 445 collisions),
|
||||||
|
// (b) served in-ISO files via an ISO9660 lookup that failed on
|
||||||
|
// UDF-only Windows 11 ISOs, and (c) required an operator
|
||||||
|
// toggle. sanboot needs none of that — just the HTTP port,
|
||||||
|
// which works in any environment. The unmodified, stock ISO is
|
||||||
|
// served at iso/<id>.iso; nothing is injected into Windows.
|
||||||
vec![BootEntry {
|
vec![BootEntry {
|
||||||
id: format!("{id}-winpe"),
|
id: format!("{id}-windows"),
|
||||||
title: format!("{title} (Windows / wimboot)"),
|
title: format!("{title} (Windows)"),
|
||||||
kind: BootKind::Wimboot {
|
kind: BootKind::SanBootIso {
|
||||||
wimboot_url: "ipxe/wimboot".to_string(),
|
iso_url: format!("iso/{id}.iso"),
|
||||||
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")),
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
@@ -587,15 +648,33 @@ fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> V
|
|||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// Last-resort SAN boot. Won't work for large modern ISOs, but
|
// No Windows-install media and no Linux kernel/initrd. Decide
|
||||||
// lets the ISO at least appear in the menu.
|
// whether the ISO is bootable at all (v0.6.0):
|
||||||
|
// * `el_torito` — it carries a boot catalog, so iPXE sanboots
|
||||||
|
// the raw image as an emulated CD: BSDs, ESXi/VMvisor
|
||||||
|
// installers, firmware tools, custom spins. The emulated CD
|
||||||
|
// is backed by HTTP range reads, so ISO size is a non-issue
|
||||||
|
// (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 {
|
vec![BootEntry {
|
||||||
id: format!("{id}-sanboot"),
|
id: format!("{id}-sanboot"),
|
||||||
title: format!("{title} (SAN boot — may fail for >1GiB ISOs)"),
|
title,
|
||||||
kind: BootKind::SanBootIso {
|
kind: BootKind::SanBootIso {
|
||||||
iso_url: format!("iso/{id}.iso"),
|
iso_url: format!("iso/{id}.iso"),
|
||||||
},
|
},
|
||||||
}]
|
}]
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -659,13 +738,59 @@ mod tests {
|
|||||||
// good.
|
// good.
|
||||||
let s = linux_cmdline(DistroFamily::DebianUbuntu, "ubuntu-24-04");
|
let s = linux_cmdline(DistroFamily::DebianUbuntu, "ubuntu-24-04");
|
||||||
assert!(s.contains("boot=casper"), "{s}");
|
assert!(s.contains("boot=casper"), "{s}");
|
||||||
assert!(s.contains("iso-url=${base-url}/iso/ubuntu-24-04.iso"), "{s}");
|
assert!(
|
||||||
|
s.contains("iso-url=${base-url}/iso/ubuntu-24-04.iso"),
|
||||||
|
"{s}"
|
||||||
|
);
|
||||||
assert!(s.contains("ds=nocloud"), "{s}");
|
assert!(s.contains("ds=nocloud"), "{s}");
|
||||||
assert!(s.contains("ip=dhcp"), "{s}");
|
assert!(s.contains("ip=dhcp"), "{s}");
|
||||||
assert!(!s.contains("netboot=url"), "legacy option leaked: {s}");
|
assert!(!s.contains("netboot=url"), "legacy option leaked: {s}");
|
||||||
assert!(!s.contains(" --- "), "stray ---: {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 {
|
fn fake_meta(id: &str) -> IsoMeta {
|
||||||
IsoMeta {
|
IsoMeta {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
@@ -673,13 +798,7 @@ mod tests {
|
|||||||
size_bytes: 0,
|
size_bytes: 0,
|
||||||
sha256_hex: None,
|
sha256_hex: None,
|
||||||
uploaded_at: OffsetDateTime::now_utc(),
|
uploaded_at: OffsetDateTime::now_utc(),
|
||||||
introspection: IntrospectionReport {
|
introspection: IntrospectionReport::default(),
|
||||||
family: DistroFamily::Unknown,
|
|
||||||
volume_label: None,
|
|
||||||
kernel_path: None,
|
|
||||||
initrd_paths: vec![],
|
|
||||||
has_boot_wim: false,
|
|
||||||
},
|
|
||||||
boot_entries: vec![],
|
boot_entries: vec![],
|
||||||
source: IsoSource::Local,
|
source: IsoSource::Local,
|
||||||
password_hash: None,
|
password_hash: None,
|
||||||
|
|||||||
+134
-9
@@ -9,7 +9,7 @@ use openpxe_core::{
|
|||||||
};
|
};
|
||||||
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, SmbManager, SmbShareManager};
|
use openpxe_iso_store::{IsoStore, NfsShareManager, SftpShareManager, SmbManager, SmbShareManager};
|
||||||
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;
|
||||||
@@ -59,11 +59,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
init_tracing(log_bus.clone());
|
init_tracing(log_bus.clone());
|
||||||
|
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let mut config = match &cli.config {
|
// v0.5.4: layered load via figment — defaults → optional TOML → env.
|
||||||
Some(p) if p.exists() => Config::from_toml_file(p)?,
|
// The OPENPXE_* env layer keeps the historical flat names (see
|
||||||
_ => Config::default(),
|
// `Config::load`), so existing deployments are unaffected.
|
||||||
};
|
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 {
|
||||||
@@ -95,14 +94,19 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let public_base_url = format!("http://{our_ip}");
|
// v0.5.6: the advertised base URL must carry the HTTP port. Every
|
||||||
|
// client-facing URL (the DHCP-proxy iPXE filename, UEFI HTTP boot,
|
||||||
|
// and the menu's kernel/initrd/ISO links) is derived from this one
|
||||||
|
// string, so omitting the port silently pointed PXE clients at :80 —
|
||||||
|
// breaking every non-80 deployment (e.g. the Unraid template's 4200,
|
||||||
|
// chosen to dodge the webGUI). See `build_public_base_url`.
|
||||||
|
let public_base_url = build_public_base_url(our_ip, config.server.http_port);
|
||||||
|
|
||||||
let iso_store = IsoStore::new(config.paths.iso_dir.clone());
|
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/
|
// v0.5.2: unattended answer-file store (Kickstart/Preseed/Autoinstall/
|
||||||
// Windows answer files). Separate directory from the ISO store.
|
// Windows answer files). Separate directory from the ISO store.
|
||||||
let unattended =
|
let unattended = openpxe_iso_store::UnattendedStore::new(config.paths.unattended_dir.clone());
|
||||||
openpxe_iso_store::UnattendedStore::new(config.paths.unattended_dir.clone());
|
|
||||||
if let Err(e) = unattended.load_from_disk().await {
|
if let Err(e) = unattended.load_from_disk().await {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "openpxe::unattended",
|
target: "openpxe::unattended",
|
||||||
@@ -156,6 +160,19 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
// required for PXE to work — they're informational, surfaced in the
|
// required for PXE to work — they're informational, surfaced in the
|
||||||
// UI so an operator doesn't have to drop to a shell to find their
|
// UI so an operator doesn't have to drop to a shell to find their
|
||||||
@@ -167,6 +184,10 @@ 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(),
|
||||||
@@ -174,7 +195,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
queue: queue.clone(),
|
queue: queue.clone(),
|
||||||
hosts: hosts.clone(),
|
hosts: hosts.clone(),
|
||||||
boot_log: boot_log.clone(),
|
boot_log: boot_log.clone(),
|
||||||
|
boot_rules: boot_rules.clone(),
|
||||||
|
boot_tokens: openpxe_core::BootTokens::new(),
|
||||||
branding: branding.clone(),
|
branding: branding.clone(),
|
||||||
|
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
|
||||||
admin: admin.clone(),
|
admin: admin.clone(),
|
||||||
sessions: sessions.clone(),
|
sessions: sessions.clone(),
|
||||||
sso: sso.clone(),
|
sso: sso.clone(),
|
||||||
@@ -184,12 +208,14 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
smb: Some(smb.clone()),
|
smb: Some(smb.clone()),
|
||||||
smb_shares: smb_shares.clone(),
|
smb_shares: smb_shares.clone(),
|
||||||
nfs_shares: nfs_shares.clone(),
|
nfs_shares: nfs_shares.clone(),
|
||||||
|
sftp_shares: sftp_shares.clone(),
|
||||||
unattended: unattended.clone(),
|
unattended: unattended.clone(),
|
||||||
uploads: openpxe_http_api::uploads::UploadSessions::default(),
|
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,
|
||||||
};
|
};
|
||||||
@@ -211,11 +237,28 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
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());
|
||||||
|
|
||||||
@@ -229,6 +272,10 @@ 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())
|
||||||
}
|
}
|
||||||
@@ -333,6 +380,20 @@ 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`
|
||||||
@@ -388,6 +449,12 @@ 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
|
||||||
@@ -448,9 +515,44 @@ 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 {
|
||||||
@@ -466,3 +568,26 @@ 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,4 +14,4 @@
|
|||||||
|
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
|
||||||
pub use server::TftpServer;
|
pub use server::{DynamicAsset, TftpServer};
|
||||||
|
|||||||
+53
-17
@@ -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_bytes` — that is,
|
//! We only serve files from `openpxe_ipxe_assets::asset_slice` — 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_bytes;
|
use openpxe_ipxe_assets::asset_slice;
|
||||||
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,6 +21,7 @@ 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;
|
||||||
@@ -31,11 +32,19 @@ 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 {
|
||||||
@@ -44,12 +53,14 @@ 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,
|
bind,
|
||||||
port,
|
port,
|
||||||
clients,
|
clients,
|
||||||
metrics,
|
metrics,
|
||||||
|
dynamic,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,8 +82,11 @@ 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) = handle_rrq(data, from, bind_ip, clients, metrics.clone()).await {
|
if let Err(e) =
|
||||||
|
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}");
|
||||||
}
|
}
|
||||||
@@ -87,18 +101,45 @@ 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, ..
|
filename,
|
||||||
|
mode,
|
||||||
|
options,
|
||||||
} = req;
|
} = req;
|
||||||
|
|
||||||
// Per-transfer ephemeral socket.
|
// Per-transfer ephemeral socket.
|
||||||
let sock = bind_udp(bind_ip, 0)?;
|
let sock = bind_udp(bind_ip, 0)?;
|
||||||
|
|
||||||
let Some(file_bytes) = asset_bytes(&filename) else {
|
// We serve binary boot artifacts; netascii line-ending translation
|
||||||
|
// 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(
|
||||||
@@ -262,7 +303,6 @@ 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)>,
|
||||||
}
|
}
|
||||||
@@ -393,17 +433,13 @@ fn bind_udp(bind: IpAddr, port: u16) -> anyhow::Result<UdpSocket> {
|
|||||||
Ok(UdpSocket::from_std(std_sock)?)
|
Ok(UdpSocket::from_std(std_sock)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
/// Pure-logic mirror of `handle_rrq`'s windowing math, exercised by the
|
||||||
const _UNUSED: (u16, u16) = (ERR_NOT_DEFINED, ERR_ILLEGAL_OP);
|
/// unit tests below. Given a position in the file and the window, return
|
||||||
|
/// the (block_no, chunk_len) list this window will emit — tested against
|
||||||
/// Pure-logic helper used by the unit tests below and (in a refactor) by
|
/// edge cases (exact-blksize tail, short tail, single-block window,
|
||||||
/// `handle_rrq`. Given a position in the file and the window, return the
|
/// block-number wraparound).
|
||||||
/// (block_no, chunk_len) list this window will emit. Useful as a sanity
|
#[cfg(test)]
|
||||||
/// check that our windowing math matches the wire behavior the spec
|
fn plan_window(
|
||||||
/// 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,
|
||||||
|
|||||||
@@ -304,6 +304,14 @@ button.ghost { background: transparent; color: var(--fg); border: 1px solid var(
|
|||||||
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;
|
||||||
@@ -904,3 +912,14 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
|
|||||||
display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px;
|
display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px;
|
||||||
}
|
}
|
||||||
.modal-actions .submit { width: auto; padding: 8px 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 (Available images / Unattended files). */
|
||||||
|
.list-search { padding: 14px 16px 0; }
|
||||||
|
.list-search input { width: 100%; }
|
||||||
|
|||||||
+397
-75
@@ -160,20 +160,36 @@
|
|||||||
// tint borrowed from Bootimus v0.1.62. Returns {ok, reason}.
|
// tint borrowed from Bootimus v0.1.62. Returns {ok, reason}.
|
||||||
function bootability(iso, settings) {
|
function bootability(iso, settings) {
|
||||||
const fam = iso.introspection.family;
|
const fam = iso.introspection.family;
|
||||||
const isWin = fam === 'windows_pe';
|
// v0.5.8: Windows ISOs boot via iPXE HTTP sanboot of the raw image —
|
||||||
if (isWin && !settings.windows_enabled) {
|
// no Settings toggle, no SMB, no size limit. Always bootable.
|
||||||
return { ok: false, reason: 'Windows boot disabled in Settings' };
|
if (fam === 'windows_pe') {
|
||||||
}
|
|
||||||
if (!isWin && !iso.introspection.kernel_path && fam !== 'windows_pe') {
|
|
||||||
// Linux without a detected kernel falls through to sanboot which
|
|
||||||
// rarely works for >1 GiB ISOs.
|
|
||||||
if (iso.size_bytes > 1.5 * 1024 * 1024 * 1024) {
|
|
||||||
return { ok: false, reason: 'no kernel/initrd detected; ISO too large for sanboot fallback' };
|
|
||||||
}
|
|
||||||
return { ok: true, warn: 'no kernel detected — sanboot fallback may not work' };
|
|
||||||
}
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
// Linux with a detected kernel/initrd — direct kernel+initrd boot.
|
||||||
|
if (iso.introspection.kernel_path) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
// v0.5.9: any other ISO that carries an El Torito boot catalog is
|
||||||
|
// bootable via iPXE sanboot (emulated CD) — BSDs, ESXi, firmware
|
||||||
|
// tools, custom Linux spins. This replaces the old "> 1.5 GB ⇒
|
||||||
|
// unbootable" size guess with the authoritative on-disk boot signal,
|
||||||
|
// so a large bootable ISO is no longer mislabeled and a Windows ISO
|
||||||
|
// re-introspected on upgrade lights up correctly.
|
||||||
|
if (iso.introspection.el_torito) {
|
||||||
|
return { ok: true, warn: 'generic bootable ISO — boots via sanboot (emulated CD)' };
|
||||||
|
}
|
||||||
|
// Remote-share ISOs aren't introspected (no random access over the
|
||||||
|
// network), so el_torito is unknown — assume bootable and let sanboot
|
||||||
|
// try rather than cry wolf.
|
||||||
|
const remote = iso.source && iso.source.kind && iso.source.kind !== 'local';
|
||||||
|
if (remote) {
|
||||||
|
return { ok: true, warn: 'remote ISO — not introspected; sanboot is attempted at boot' };
|
||||||
|
}
|
||||||
|
// Local ISO with no Windows/Linux boot files and no El Torito catalog:
|
||||||
|
// a data/appliance image (e.g. a VMware vCenter bundle), not a bootable
|
||||||
|
// installer.
|
||||||
|
return { ok: false, reason: 'data/appliance ISO — no El Torito boot catalog and no Windows/Linux installer files, so it can’t be PXE-booted' };
|
||||||
|
}
|
||||||
|
|
||||||
// v0.5.2: pretty label for an unattended file's detected kind.
|
// v0.5.2: pretty label for an unattended file's detected kind.
|
||||||
function unattendedKindLabel(k) {
|
function unattendedKindLabel(k) {
|
||||||
@@ -183,6 +199,51 @@
|
|||||||
})[k] || (k || 'Unknown');
|
})[k] || (k || 'Unknown');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.7.2: compact read-out of saved group rules — created from the
|
||||||
|
// unified "Pin MAC" form on the Hosts tab (a prefix or an architecture
|
||||||
|
// there saves a rule instead of a pin). First match wins, top to
|
||||||
|
// bottom. The boot-decision webhook remains available via the API
|
||||||
|
// (/api/boot-rules `webhook_url`) but no longer has a UI knob.
|
||||||
|
function groupRulesCard(cfg, targetOptions) {
|
||||||
|
const rules = (cfg && cfg.rules) || [];
|
||||||
|
if (!rules.length) return null;
|
||||||
|
const titleFor = id => {
|
||||||
|
const t = targetOptions.find(x => x.id === id);
|
||||||
|
return t ? t.title : id;
|
||||||
|
};
|
||||||
|
const modeLabel = {firmware:'Firmware NIC', builtin:'iPXE drivers', shim:'Secure Boot (shim)'};
|
||||||
|
const rows = rules.map((r, i) => el('tr', r.enabled === false ? {style:'opacity:.5'} : {}, [
|
||||||
|
el('td', {class:'mono'}, r.mac_prefix || el('span', {class:'tag'}, 'any MAC')),
|
||||||
|
el('td', {}, r.arch || el('span', {class:'tag'}, 'any arch')),
|
||||||
|
el('td', {}, r.target ? titleFor(r.target) : el('span', {class:'tag'}, '—')),
|
||||||
|
el('td', {}, r.driver_mode
|
||||||
|
? el('span', {class:'tag accent'}, modeLabel[r.driver_mode] || r.driver_mode)
|
||||||
|
: el('span', {class:'tag'}, 'auto')),
|
||||||
|
el('td', {}, r.note || ''),
|
||||||
|
el('td', {style:'text-align:right'},
|
||||||
|
el('button', {class:'danger', onclick: async () => {
|
||||||
|
if (!confirm('Remove this group rule?')) return;
|
||||||
|
const fresh = await getJSON('/api/boot-rules').catch(() => ({rules: [], webhook_url: ''}));
|
||||||
|
(fresh.rules = fresh.rules || []).splice(i, 1);
|
||||||
|
await putJSON('/api/boot-rules', fresh);
|
||||||
|
render('hosts');
|
||||||
|
}}, 'Remove')),
|
||||||
|
]));
|
||||||
|
return el('div', {class:'card'}, [
|
||||||
|
el('header', {}, [
|
||||||
|
el('h2', {}, 'Group rules'),
|
||||||
|
el('span', {class:'sub'}, 'first match wins · checked top to bottom'),
|
||||||
|
]),
|
||||||
|
el('table', {}, [
|
||||||
|
el('thead', {}, el('tr', {}, [
|
||||||
|
el('th',{},'MAC prefix'), el('th',{},'Arch'), el('th',{},'Target'),
|
||||||
|
el('th',{},'Boot binary'), el('th',{},'Note'), el('th',{},''),
|
||||||
|
])),
|
||||||
|
el('tbody', {}, rows),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// v0.5.2: build the shared "deployment profile" field group — auto
|
// v0.5.2: build the shared "deployment profile" field group — auto
|
||||||
// hostname, auto IP, and an unattended-file picker — reused by the
|
// hostname, auto IP, and an unattended-file picker — reused by the
|
||||||
// Hosts pin form and the Queue "Profile" modal. `files` is the
|
// Hosts pin form and the Queue "Profile" modal. `files` is the
|
||||||
@@ -288,14 +349,23 @@
|
|||||||
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
||||||
el('div', {class: 'label'}, 'Images available'),
|
el('div', {class: 'label'}, 'Images available'),
|
||||||
el('div', {class: 'value'}, String(isos.length)),
|
el('div', {class: 'value'}, String(isos.length)),
|
||||||
el('div', {class: 'trend'},
|
el('div', {class: 'trend'}, (() => {
|
||||||
isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' +
|
// v0.5.9: count families honestly. Anything that isn't a known
|
||||||
isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux · ' +
|
// Linux family or Windows lands in "other" (data/appliance ISOs
|
||||||
// v0.4.67: count both protocols. Label generically since
|
// like VMware VCSA, or as-yet-unclassified images) instead of
|
||||||
// operators may be using one, the other, or both.
|
// being lumped under "Linux".
|
||||||
((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0)) +
|
const LINUX = ['debian_ubuntu', 'rhel_fedora', 'opensuse', 'arch', 'alpine'];
|
||||||
' remote share' +
|
const win = isos.filter(i => i.introspection.family === 'windows_pe').length;
|
||||||
(((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0)) === 1 ? '' : 's')),
|
const lin = isos.filter(i => LINUX.includes(i.introspection.family)).length;
|
||||||
|
const other = isos.length - win - lin;
|
||||||
|
// v0.4.67+v0.5.5: count all remote-share protocols. Label
|
||||||
|
// generically since operators may use any mix of SMB/NFS/SFTP.
|
||||||
|
const remote = (status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0);
|
||||||
|
const parts = [win + ' Windows', lin + ' Linux'];
|
||||||
|
if (other > 0) parts.push(other + ' other');
|
||||||
|
parts.push(remote + ' remote share' + (remote === 1 ? '' : 's'));
|
||||||
|
return parts.join(' · ');
|
||||||
|
})()),
|
||||||
])),
|
])),
|
||||||
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
||||||
el('div', {class: 'label'}, 'Uptime'),
|
el('div', {class: 'label'}, 'Uptime'),
|
||||||
@@ -340,7 +410,7 @@
|
|||||||
const settings = status.settings;
|
const settings = status.settings;
|
||||||
const problems = isos.map(i => ({i, b: bootability(i, settings)})).filter(x => !x.b.ok);
|
const problems = isos.map(i => ({i, b: bootability(i, settings)})).filter(x => !x.b.ok);
|
||||||
const problemsBlock = problems.length ? el('div', {class:'card'}, [
|
const problemsBlock = problems.length ? el('div', {class:'card'}, [
|
||||||
el('header', {}, [el('h2', {}, 'Images that won\'t boot with current settings')]),
|
el('header', {}, [el('h2', {}, 'Non-bootable images')]),
|
||||||
el('div', {class:'body'},
|
el('div', {class:'body'},
|
||||||
problems.map(({i, b}) => el('div', {class:'row-warn'},
|
problems.map(({i, b}) => el('div', {class:'row-warn'},
|
||||||
'⚠ ' + i.filename + ' — ' + b.reason)))
|
'⚠ ' + i.filename + ' — ' + b.reason)))
|
||||||
@@ -369,6 +439,11 @@
|
|||||||
el('div', {class:'v'}, net.server_ip || '?'),
|
el('div', {class:'v'}, net.server_ip || '?'),
|
||||||
el('div', {class:'k'}, 'NIC name'),
|
el('div', {class:'k'}, 'NIC name'),
|
||||||
el('div', {class:'v'}, net.nic_name || '(auto-detect failed)'),
|
el('div', {class:'v'}, net.nic_name || '(auto-detect failed)'),
|
||||||
|
// v0.7.2: physical link details (operstate · speed · duplex ·
|
||||||
|
// port MAC) so the operator can confirm WHICH port answers
|
||||||
|
// PXE in multi-NIC / trunked environments.
|
||||||
|
el('div', {class:'k'}, 'Link'),
|
||||||
|
el('div', {class:'v'}, net.nic_link || '—'),
|
||||||
el('div', {class:'k'}, 'Subnet mask'),
|
el('div', {class:'k'}, 'Subnet mask'),
|
||||||
el('div', {class:'v'}, net.subnet_mask || '?'),
|
el('div', {class:'v'}, net.subnet_mask || '?'),
|
||||||
el('div', {class:'k'}, 'Gateway'),
|
el('div', {class:'k'}, 'Gateway'),
|
||||||
@@ -508,10 +583,11 @@
|
|||||||
// v0.4.67: NFSv3 added back as an in-process Rust client
|
// v0.4.67: NFSv3 added back as an in-process Rust client
|
||||||
// (nfs3_client crate). Both protocols available side-by-side;
|
// (nfs3_client crate). Both protocols available side-by-side;
|
||||||
// operators pick whichever their NAS prefers.
|
// operators pick whichever their NAS prefers.
|
||||||
const [isos, settings, smbRes, nfsRes, disk, unattRes] = await Promise.all([
|
const [isos, settings, smbRes, nfsRes, sftpRes, disk, unattRes] = await Promise.all([
|
||||||
getJSON('/api/isos'), getJSON('/api/settings'),
|
getJSON('/api/isos'), getJSON('/api/settings'),
|
||||||
getJSON('/api/smb-shares'),
|
getJSON('/api/smb-shares'),
|
||||||
getJSON('/api/nfs-shares'),
|
getJSON('/api/nfs-shares'),
|
||||||
|
getJSON('/api/sftp-shares'),
|
||||||
getJSON('/api/storage/disk').catch(() => ({
|
getJSON('/api/storage/disk').catch(() => ({
|
||||||
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
|
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
|
||||||
})),
|
})),
|
||||||
@@ -519,6 +595,7 @@
|
|||||||
]);
|
]);
|
||||||
const shares = smbRes.shares || [];
|
const shares = smbRes.shares || [];
|
||||||
const nfsShares = nfsRes.shares || [];
|
const nfsShares = nfsRes.shares || [];
|
||||||
|
const sftpShares = sftpRes.shares || [];
|
||||||
const unattendedFiles = unattRes.files || [];
|
const unattendedFiles = unattRes.files || [];
|
||||||
|
|
||||||
// ── Upload card ──
|
// ── Upload card ──
|
||||||
@@ -531,6 +608,9 @@
|
|||||||
style:'display:none', id:'file'});
|
style:'display:none', id:'file'});
|
||||||
const prog = el('div', {class:'progress', id:'prog'}, el('div', {class:'bar', id:'bar'}));
|
const prog = el('div', {class:'progress', id:'prog'}, el('div', {class:'bar', id:'bar'}));
|
||||||
const upMsg = el('div', {class:'msg', id:'upmsg'});
|
const upMsg = el('div', {class:'msg', id:'upmsg'});
|
||||||
|
// v0.5.8: cancel button — shown only while an upload is in flight.
|
||||||
|
const cancelUpload = el('button', {class:'danger', type:'button',
|
||||||
|
style:'display:none;margin-top:12px', id:'cancel-upload'}, 'Cancel upload');
|
||||||
|
|
||||||
drop.onclick = () => file.click();
|
drop.onclick = () => file.click();
|
||||||
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('hover'); });
|
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('hover'); });
|
||||||
@@ -573,6 +653,16 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
let uploadId = null;
|
let uploadId = null;
|
||||||
|
// v0.5.8: cancel + leave-page guard. The AbortController stops the
|
||||||
|
// in-flight chunk; the beforeunload listener warns the operator
|
||||||
|
// that navigating away aborts the upload (the server-side partial
|
||||||
|
// is then cleaned up by the DELETE in the catch below).
|
||||||
|
const ac = new AbortController();
|
||||||
|
let canceled = false;
|
||||||
|
const warnLeave = (e) => { e.preventDefault(); e.returnValue = ''; return ''; };
|
||||||
|
window.addEventListener('beforeunload', warnLeave);
|
||||||
|
cancelUpload.style.display = '';
|
||||||
|
cancelUpload.onclick = () => { canceled = true; ac.abort(); };
|
||||||
setStatus('Preparing upload for ' + f.name + ' (' + fmtBytes(f.size) + ')');
|
setStatus('Preparing upload for ' + f.name + ' (' + fmtBytes(f.size) + ')');
|
||||||
prog.classList.add('active');
|
prog.classList.add('active');
|
||||||
bar.style.width = '1%';
|
bar.style.width = '1%';
|
||||||
@@ -599,6 +689,7 @@
|
|||||||
'x-openpxe-upload-complete': complete ? 'true' : 'false',
|
'x-openpxe-upload-complete': complete ? 'true' : 'false',
|
||||||
},
|
},
|
||||||
body: f.slice(offset, end),
|
body: f.slice(offset, end),
|
||||||
|
signal: ac.signal,
|
||||||
});
|
});
|
||||||
if (!r.ok) throw new Error(await failText(r));
|
if (!r.ok) throw new Error(await failText(r));
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
@@ -614,8 +705,15 @@
|
|||||||
try { await fetch('/api/uploads/' + encodeURIComponent(uploadId), {method: 'DELETE'}); }
|
try { await fetch('/api/uploads/' + encodeURIComponent(uploadId), {method: 'DELETE'}); }
|
||||||
catch {}
|
catch {}
|
||||||
}
|
}
|
||||||
|
if (canceled || (err && err.name === 'AbortError')) {
|
||||||
|
setStatus('Upload canceled — partial file discarded.', '');
|
||||||
|
} else {
|
||||||
setStatus('Upload failed: ' + (err && err.message ? err.message : String(err)), 'err');
|
setStatus('Upload failed: ' + (err && err.message ? err.message : String(err)), 'err');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
window.removeEventListener('beforeunload', warnLeave);
|
||||||
|
cancelUpload.style.display = 'none';
|
||||||
|
cancelUpload.onclick = null;
|
||||||
prog.classList.remove('active');
|
prog.classList.remove('active');
|
||||||
if (!upMsg.className.includes('ok')) bar.style.width = '0';
|
if (!upMsg.className.includes('ok')) bar.style.width = '0';
|
||||||
}
|
}
|
||||||
@@ -627,7 +725,11 @@
|
|||||||
// *next* row of the table. Keeps the markup flat and avoids the
|
// *next* row of the table. Keeps the markup flat and avoids the
|
||||||
// overhead of a real modal.
|
// overhead of a real modal.
|
||||||
const rowsAndEditors = [];
|
const rowsAndEditors = [];
|
||||||
isos.forEach(i => {
|
// v0.5.8: list Available images alphabetically by filename
|
||||||
|
// (case-insensitive, natural numeric order) instead of newest-first.
|
||||||
|
const sortedIsos = [...isos].sort((a, b) =>
|
||||||
|
(a.filename || '').localeCompare(b.filename || '', undefined, { sensitivity: 'base', numeric: true }));
|
||||||
|
sortedIsos.forEach(i => {
|
||||||
const b = bootability(i, settings);
|
const b = bootability(i, settings);
|
||||||
// v0.4.65: SMB userspace consumer (smbclient).
|
// v0.4.65: SMB userspace consumer (smbclient).
|
||||||
// v0.4.67: NFS back as in-process Rust client (nfs3_client).
|
// v0.4.67: NFS back as in-process Rust client (nfs3_client).
|
||||||
@@ -737,6 +839,12 @@
|
|||||||
render('storage');
|
render('storage');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// v0.7.2: searchable haystack for the list filter — filename,
|
||||||
|
// detected family, category, and source all match.
|
||||||
|
const searchText = [
|
||||||
|
i.filename, familyLabel(i.introspection.family), i.category || '',
|
||||||
|
isSmb ? 'smb' : isNfs ? 'nfs' : 'local', i.id,
|
||||||
|
].join(' ').toLowerCase();
|
||||||
const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [
|
const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [
|
||||||
el('td', {}, [
|
el('td', {}, [
|
||||||
el('div', {style:'display:flex;align-items:center;gap:8px'}, [
|
el('div', {style:'display:flex;align-items:center;gap:8px'}, [
|
||||||
@@ -781,8 +889,24 @@
|
|||||||
}}, 'Remove'),
|
}}, 'Remove'),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
tr.dataset.search = searchText;
|
||||||
rowsAndEditors.push(tr, editorRow);
|
rowsAndEditors.push(tr, editorRow);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v0.7.2: client-side filter over the image table. Rows travel in
|
||||||
|
// (row, password-editor) pairs; filtering hides both, and an open
|
||||||
|
// editor stays closed for filtered-out rows.
|
||||||
|
const isoSearch = el('input', {type:'search', placeholder:'Filter images… (name, family, category, source)',
|
||||||
|
spellcheck:'false', oninput: () => {
|
||||||
|
const q = isoSearch.value.trim().toLowerCase();
|
||||||
|
for (let k = 0; k + 1 < rowsAndEditors.length; k += 2) {
|
||||||
|
const row = rowsAndEditors[k];
|
||||||
|
const editor = rowsAndEditors[k + 1];
|
||||||
|
const show = !q || (row.dataset.search || '').includes(q);
|
||||||
|
row.style.display = show ? '' : 'none';
|
||||||
|
if (!show) editor.style.display = 'none';
|
||||||
|
}
|
||||||
|
}});
|
||||||
const isoTable = isos.length
|
const isoTable = isos.length
|
||||||
? el('table', {}, [
|
? el('table', {}, [
|
||||||
el('thead', {}, el('tr', {}, [
|
el('thead', {}, el('tr', {}, [
|
||||||
@@ -829,6 +953,7 @@
|
|||||||
const protoSelect = el('select', {}, [
|
const protoSelect = el('select', {}, [
|
||||||
el('option', {value:'nfs'}, 'NFS (NFSv3)'),
|
el('option', {value:'nfs'}, 'NFS (NFSv3)'),
|
||||||
el('option', {value:'smb'}, 'SMB / CIFS'),
|
el('option', {value:'smb'}, 'SMB / CIFS'),
|
||||||
|
el('option', {value:'sftp'}, 'SFTP (SSH)'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// SMB inputs.
|
// SMB inputs.
|
||||||
@@ -893,18 +1018,65 @@
|
|||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// SFTP inputs (v0.5.5). Pure-Rust russh client, in-process, so
|
||||||
|
// SFTP-sourced ISOs support HTTP Range like NFS. Auth is password
|
||||||
|
// OR an SSH private key (PEM, optional passphrase); the server's
|
||||||
|
// host key is pinned trust-on-first-use on the first connect.
|
||||||
|
const sftpServerIn = el('input', {type:'text', placeholder:'10.0.0.5'});
|
||||||
|
const sftpExportIn = el('input', {type:'text', placeholder:'/srv/isos'});
|
||||||
|
const sftpUserIn = el('input', {type:'text', placeholder:'root'});
|
||||||
|
const sftpPortIn = el('input', {type:'number', placeholder:'22', min:'1', max:'65535'});
|
||||||
|
const sftpAuthMode = el('select', {}, [
|
||||||
|
el('option', {value:'password'}, 'Password'),
|
||||||
|
el('option', {value:'key'}, 'SSH private key'),
|
||||||
|
]);
|
||||||
|
const sftpPassIn = el('input', {type:'password', placeholder:'••••••••'});
|
||||||
|
const sftpKeyIn = el('textarea', {rows:'4',
|
||||||
|
placeholder:'-----BEGIN OPENSSH PRIVATE KEY-----',
|
||||||
|
style:'width:100%;font-family:ui-monospace,monospace;font-size:12px;resize:vertical'});
|
||||||
|
const sftpPassphraseIn = el('input', {type:'password',
|
||||||
|
placeholder:'(only if the private key is encrypted)'});
|
||||||
|
const sftpPassBlock = el('label', {class:'field'},
|
||||||
|
[el('span', {class:'name'}, 'Password'), sftpPassIn]);
|
||||||
|
const sftpKeyBlock = el('div', {}, [
|
||||||
|
el('label', {class:'field'},
|
||||||
|
[el('span', {class:'name'}, 'SSH private key (PEM)'), sftpKeyIn]),
|
||||||
|
el('label', {class:'field', style:'margin-top:10px'},
|
||||||
|
[el('span', {class:'name'}, 'Key passphrase (optional)'), sftpPassphraseIn]),
|
||||||
|
]);
|
||||||
|
const syncSftpAuth = () => {
|
||||||
|
const key = sftpAuthMode.value === 'key';
|
||||||
|
sftpPassBlock.style.display = key ? 'none' : '';
|
||||||
|
sftpKeyBlock.style.display = key ? '' : 'none';
|
||||||
|
};
|
||||||
|
sftpAuthMode.addEventListener('change', syncSftpAuth);
|
||||||
|
syncSftpAuth();
|
||||||
|
const sftpFields = el('div', {}, [
|
||||||
|
el('div', {class:'form-row cols-2'}, [
|
||||||
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'SSH server'), sftpServerIn]),
|
||||||
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'Export path'), sftpExportIn]),
|
||||||
|
]),
|
||||||
|
el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [
|
||||||
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'Username'), sftpUserIn]),
|
||||||
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'Port'), sftpPortIn]),
|
||||||
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'Auth'), sftpAuthMode]),
|
||||||
|
]),
|
||||||
|
el('div', {style:'margin-top:14px'}, [sftpPassBlock, sftpKeyBlock]),
|
||||||
|
]);
|
||||||
|
|
||||||
// Swap the visible field block + clear any stale message.
|
// Swap the visible field block + clear any stale message.
|
||||||
const syncProto = () => {
|
const syncProto = () => {
|
||||||
const nfs = protoSelect.value === 'nfs';
|
const p = protoSelect.value;
|
||||||
smbFields.style.display = nfs ? 'none' : '';
|
smbFields.style.display = p === 'smb' ? '' : 'none';
|
||||||
nfsFields.style.display = nfs ? '' : 'none';
|
nfsFields.style.display = p === 'nfs' ? '' : 'none';
|
||||||
|
sftpFields.style.display = p === 'sftp' ? '' : 'none';
|
||||||
shareMsg.replaceChildren();
|
shareMsg.replaceChildren();
|
||||||
shareMsg.className = 'msg';
|
shareMsg.className = 'msg';
|
||||||
};
|
};
|
||||||
protoSelect.addEventListener('change', syncProto);
|
protoSelect.addEventListener('change', syncProto);
|
||||||
|
|
||||||
// One add button; dispatches to the selected protocol's endpoint.
|
// One add button; dispatches to the selected protocol's endpoint.
|
||||||
const addShare = el('button', {style:'margin-top:14px', onclick: async () => {
|
const addShare = el('button', {onclick: async () => {
|
||||||
if (protoSelect.value === 'smb') {
|
if (protoSelect.value === 'smb') {
|
||||||
if (!smbServer.value || !smbShare.value) {
|
if (!smbServer.value || !smbShare.value) {
|
||||||
shareMsg.replaceChildren(document.createTextNode('Server and share name are required.'));
|
shareMsg.replaceChildren(document.createTextNode('Server and share name are required.'));
|
||||||
@@ -924,6 +1096,40 @@
|
|||||||
shareMsg.className = 'msg ok';
|
shareMsg.className = 'msg ok';
|
||||||
render('storage');
|
render('storage');
|
||||||
} else { await showShareError(r); }
|
} else { await showShareError(r); }
|
||||||
|
} else if (protoSelect.value === 'sftp') {
|
||||||
|
if (!sftpServerIn.value || !sftpExportIn.value || !sftpUserIn.value) {
|
||||||
|
shareMsg.replaceChildren(document.createTextNode('Server, export, and username are required.'));
|
||||||
|
shareMsg.className = 'msg err'; return;
|
||||||
|
}
|
||||||
|
const useKey = sftpAuthMode.value === 'key';
|
||||||
|
if (useKey && !sftpKeyIn.value.trim()) {
|
||||||
|
shareMsg.replaceChildren(document.createTextNode('Paste the SSH private key, or switch Auth to Password.'));
|
||||||
|
shareMsg.className = 'msg err'; return;
|
||||||
|
}
|
||||||
|
if (!useKey && !sftpPassIn.value) {
|
||||||
|
shareMsg.replaceChildren(document.createTextNode('Password is required, or switch Auth to SSH private key.'));
|
||||||
|
shareMsg.className = 'msg err'; return;
|
||||||
|
}
|
||||||
|
shareMsg.replaceChildren(document.createTextNode('Connecting…'));
|
||||||
|
shareMsg.className = 'msg';
|
||||||
|
const body = {
|
||||||
|
server: sftpServerIn.value,
|
||||||
|
export: sftpExportIn.value,
|
||||||
|
username: sftpUserIn.value,
|
||||||
|
};
|
||||||
|
if (sftpPortIn.value) { body.port = parseInt(sftpPortIn.value, 10); }
|
||||||
|
if (useKey) {
|
||||||
|
body.private_key = sftpKeyIn.value;
|
||||||
|
if (sftpPassphraseIn.value) { body.passphrase = sftpPassphraseIn.value; }
|
||||||
|
} else {
|
||||||
|
body.password = sftpPassIn.value;
|
||||||
|
}
|
||||||
|
const r = await postJSON('/api/sftp-shares', body);
|
||||||
|
if (r.ok) {
|
||||||
|
shareMsg.replaceChildren(document.createTextNode('Connected.'));
|
||||||
|
shareMsg.className = 'msg ok';
|
||||||
|
render('storage');
|
||||||
|
} else { await showShareError(r); }
|
||||||
} else {
|
} else {
|
||||||
if (!nfsServerIn.value || !nfsExportIn.value) {
|
if (!nfsServerIn.value || !nfsExportIn.value) {
|
||||||
shareMsg.replaceChildren(document.createTextNode('Server and export are required.'));
|
shareMsg.replaceChildren(document.createTextNode('Server and export are required.'));
|
||||||
@@ -962,9 +1168,36 @@
|
|||||||
el('span'),
|
el('span'),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
const totalShares = shares.length + nfsShares.length;
|
const sftpRowEls = sftpShares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
|
||||||
|
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
|
||||||
|
el('div', {}, [
|
||||||
|
el('div', {class:'id'}, [el('span', {class:'proto-badge'}, 'SFTP'),
|
||||||
|
document.createTextNode(m.username + '@' + m.server + ':' + m.export)]),
|
||||||
|
el('div', {class:'meta'},
|
||||||
|
'SSH · ' + (m.auth === 'key' ? 'key' : 'password') + ' · ' +
|
||||||
|
(m.reachable ? m.iso_count + ' isos' : 'not reachable')),
|
||||||
|
m.host_key_fingerprint
|
||||||
|
? el('div', {style:'margin-top:4px;opacity:.65;font-size:11px;font-family:ui-monospace,monospace;word-break:break-all'},
|
||||||
|
'host key ' + m.host_key_fingerprint)
|
||||||
|
: null,
|
||||||
|
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
|
||||||
|
m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
|
||||||
|
]),
|
||||||
|
el('button', {class:'ghost', onclick: async () => {
|
||||||
|
const r = await postJSON('/api/sftp-shares/' + encodeURIComponent(m.id) + '/scan', {});
|
||||||
|
if (r.ok) render('storage');
|
||||||
|
}}, 'Re-scan'),
|
||||||
|
el('button', {class:'danger', onclick: async () => {
|
||||||
|
if (!confirm('Forget ' + m.server + ':' + m.export + '?')) return;
|
||||||
|
await fetch('/api/sftp-shares/' + encodeURIComponent(m.id), {method:'DELETE'});
|
||||||
|
render('storage');
|
||||||
|
}}, 'Remove'),
|
||||||
|
el('span'),
|
||||||
|
]));
|
||||||
|
|
||||||
|
const totalShares = shares.length + nfsShares.length + sftpShares.length;
|
||||||
const remoteRows = totalShares
|
const remoteRows = totalShares
|
||||||
? [...smbRowEls, ...nfsRowEls]
|
? [...smbRowEls, ...nfsRowEls, ...sftpRowEls]
|
||||||
: [el('div', {class:'empty'}, 'No remote shares configured.')];
|
: [el('div', {class:'empty'}, 'No remote shares configured.')];
|
||||||
syncProto();
|
syncProto();
|
||||||
|
|
||||||
@@ -1011,7 +1244,10 @@
|
|||||||
unattFile.onchange = () => { if (unattFile.files[0]) uploadUnattended(unattFile.files[0]); };
|
unattFile.onchange = () => { if (unattFile.files[0]) uploadUnattended(unattFile.files[0]); };
|
||||||
|
|
||||||
const unattRows = unattendedFiles.length
|
const unattRows = unattendedFiles.length
|
||||||
? unattendedFiles.map(f => el('div', {class:'nfs-row'}, [
|
? unattendedFiles.map(f => el('div', {
|
||||||
|
class:'nfs-row',
|
||||||
|
'data-search': (f.filename + ' ' + unattendedKindLabel(f.kind) + ' ' + f.id).toLowerCase(),
|
||||||
|
}, [
|
||||||
el('span', {class:'dot ok'}),
|
el('span', {class:'dot ok'}),
|
||||||
el('div', {}, [
|
el('div', {}, [
|
||||||
el('div', {class:'id'}, [
|
el('div', {class:'id'}, [
|
||||||
@@ -1039,6 +1275,17 @@
|
|||||||
]),
|
]),
|
||||||
el('div', {class:'body'}, [
|
el('div', {class:'body'}, [
|
||||||
unattDrop, unattFile, unattMsg,
|
unattDrop, unattFile, unattMsg,
|
||||||
|
// v0.7.2: filter for big answer-file libraries.
|
||||||
|
unattendedFiles.length > 1 ? (() => {
|
||||||
|
const search = el('input', {type:'search', placeholder:'Filter files… (name, kind)',
|
||||||
|
spellcheck:'false', style:'margin-top:14px', oninput: () => {
|
||||||
|
const q = search.value.trim().toLowerCase();
|
||||||
|
unattRows.forEach(r => {
|
||||||
|
r.style.display = (!q || (r.dataset.search || '').includes(q)) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}});
|
||||||
|
return search;
|
||||||
|
})() : null,
|
||||||
el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows),
|
el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows),
|
||||||
el('p', {class:'msg', style:'margin-top:14px'},
|
el('p', {class:'msg', style:'margin-top:14px'},
|
||||||
'These answer files drive unattended installs. Attach one to a ' +
|
'These answer files drive unattended installs. Attach one to a ' +
|
||||||
@@ -1053,7 +1300,7 @@
|
|||||||
diskCard,
|
diskCard,
|
||||||
el('div', {class:'card'}, [
|
el('div', {class:'card'}, [
|
||||||
el('header', {}, el('h2', {}, 'Upload ISO')),
|
el('header', {}, el('h2', {}, 'Upload ISO')),
|
||||||
el('div', {class:'body'}, [drop, file, prog, upMsg]),
|
el('div', {class:'body'}, [drop, file, prog, upMsg, cancelUpload]),
|
||||||
]),
|
]),
|
||||||
// v0.5.1: SMB + NFS unified into one "Remote shares" card with a
|
// v0.5.1: SMB + NFS unified into one "Remote shares" card with a
|
||||||
// protocol dropdown. Backend endpoints are unchanged; this is a
|
// protocol dropdown. Backend endpoints are unchanged; this is a
|
||||||
@@ -1071,13 +1318,13 @@
|
|||||||
]),
|
]),
|
||||||
el('span'),
|
el('span'),
|
||||||
]),
|
]),
|
||||||
el('div', {style:'margin-top:14px'}, [smbFields, nfsFields]),
|
el('div', {style:'margin-top:14px'}, [smbFields, nfsFields, sftpFields]),
|
||||||
addShare, shareMsg,
|
addShare, shareMsg,
|
||||||
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, remoteRows),
|
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, remoteRows),
|
||||||
el('p', {class:'msg', style:'margin-top:14px'},
|
el('p', {class:'msg', style:'margin-top:14px'},
|
||||||
'Remote .iso libraries are read on demand — no local cache to ' +
|
'Remote .iso libraries are read on demand — no local cache to ' +
|
||||||
'preserve disk usage. Support for NFS 3.0 and SMB. Ensure that ' +
|
'preserve disk usage. Support for NFS 3.0, SMB, and SFTP (SSH). ' +
|
||||||
'the hosts IP address is provisioned.'),
|
'Ensure that the hosts IP address is provisioned.'),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
el('div', {class:'card'}, [
|
el('div', {class:'card'}, [
|
||||||
@@ -1085,16 +1332,18 @@
|
|||||||
el('h2', {}, 'Available images'),
|
el('h2', {}, 'Available images'),
|
||||||
el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')),
|
el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')),
|
||||||
]),
|
]),
|
||||||
|
isos.length > 1 ? el('div', {class:'list-search'}, isoSearch) : null,
|
||||||
isoTable,
|
isoTable,
|
||||||
]),
|
]),
|
||||||
]), unattendedAdvanced]);
|
]), unattendedAdvanced]);
|
||||||
},
|
},
|
||||||
|
|
||||||
hosts: async () => {
|
hosts: async () => {
|
||||||
const [{ hosts = [] }, isos, bootLogRes, unattRes] = await Promise.all([
|
const [{ hosts = [] }, isos, bootLogRes, unattRes, rulesCfg] = await Promise.all([
|
||||||
getJSON('/api/hosts'), getJSON('/api/isos'),
|
getJSON('/api/hosts'), getJSON('/api/isos'),
|
||||||
getJSON('/api/boot-log').catch(() => ({ events: [] })),
|
getJSON('/api/boot-log').catch(() => ({ events: [] })),
|
||||||
getJSON('/api/unattended').catch(() => ({ files: [] })),
|
getJSON('/api/unattended').catch(() => ({ files: [] })),
|
||||||
|
getJSON('/api/boot-rules').catch(() => ({ rules: [], webhook_url: '' })),
|
||||||
]);
|
]);
|
||||||
const bootEvents = bootLogRes.events || [];
|
const bootEvents = bootLogRes.events || [];
|
||||||
const unattendedFiles = unattRes.files || [];
|
const unattendedFiles = unattRes.files || [];
|
||||||
@@ -1109,8 +1358,22 @@
|
|||||||
{id: '_tools_menu', title: '↳ Tools menu (built-in)'},
|
{id: '_tools_menu', title: '↳ Tools menu (built-in)'},
|
||||||
];
|
];
|
||||||
|
|
||||||
const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff', spellcheck:'false'});
|
const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff or prefix aa:bb:cc', spellcheck:'false'});
|
||||||
const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'});
|
const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'});
|
||||||
|
// v0.7.2: the former separate "Boot rules" card folded into this
|
||||||
|
// form. A full MAC with no architecture saves a per-host pin
|
||||||
|
// exactly as before; a MAC *prefix* and/or an architecture saves a
|
||||||
|
// first-match-wins group rule instead. Same form, one mental model.
|
||||||
|
const archSel = el('select', {}, [
|
||||||
|
['', 'any (this exact MAC)'], ['bios', 'BIOS'], ['uefi-x64', 'UEFI x64'],
|
||||||
|
['uefi-ia32', 'UEFI IA32'], ['uefi-arm64', 'UEFI ARM64'],
|
||||||
|
].map(([v, t]) => el('option', {value: v}, t)));
|
||||||
|
// v0.7.1's boot-binary pin keeps its home here too (auto = let the
|
||||||
|
// escalation ladder learn; shim = known Secure Boot fleet).
|
||||||
|
const binSel = el('select', {}, [
|
||||||
|
['', 'auto (learn per machine)'], ['firmware', 'Firmware NIC'],
|
||||||
|
['builtin', 'iPXE drivers'], ['shim', 'Secure Boot (shim)'],
|
||||||
|
].map(([v, t]) => el('option', {value: v}, t)));
|
||||||
const targetSel = el('select', {},
|
const targetSel = el('select', {},
|
||||||
[el('option', {value:''}, '— choose a target —')]
|
[el('option', {value:''}, '— choose a target —')]
|
||||||
.concat(reserved.map(t => el('option', {value: t.id}, t.title)))
|
.concat(reserved.map(t => el('option', {value: t.id}, t.title)))
|
||||||
@@ -1123,21 +1386,40 @@
|
|||||||
// hostname/IP templated into the served answer file.
|
// hostname/IP templated into the served answer file.
|
||||||
const profileFields = buildProfileFields({}, unattendedFiles, 'form-row cols-3');
|
const profileFields = buildProfileFields({}, unattendedFiles, 'form-row cols-3');
|
||||||
|
|
||||||
|
const FULL_MAC = /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i;
|
||||||
const upsertBtn = el('button', {onclick: async () => {
|
const upsertBtn = el('button', {onclick: async () => {
|
||||||
if (!macInput.value || !targetSel.value) {
|
const mac = macInput.value.trim();
|
||||||
|
const isGroup = !!archSel.value || !!binSel.value || (mac !== '' && !FULL_MAC.test(mac));
|
||||||
|
if (!isGroup) {
|
||||||
|
// Exact-MAC pin — unchanged behavior.
|
||||||
|
if (!mac || !targetSel.value) {
|
||||||
msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
|
msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
|
||||||
}
|
}
|
||||||
const r = await postJSON('/api/hosts', Object.assign({
|
const r = await postJSON('/api/hosts', Object.assign({
|
||||||
mac: macInput.value, target: targetSel.value, label: labelInput.value,
|
mac, target: targetSel.value, label: labelInput.value,
|
||||||
}, profileFields.read()));
|
}, profileFields.read()));
|
||||||
if (r.ok) {
|
if (r.ok) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; render('hosts'); }
|
||||||
msg.textContent = 'Saved.'; msg.className = 'msg ok';
|
else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
|
||||||
render('hosts');
|
return;
|
||||||
} else {
|
|
||||||
const t = await r.text();
|
|
||||||
msg.textContent = 'Save failed: ' + t; msg.className = 'msg err';
|
|
||||||
}
|
}
|
||||||
}}, 'Bind MAC to target');
|
// Group rule (prefix and/or architecture). Per-host profile
|
||||||
|
// fields don't apply to a group — they're per-machine values.
|
||||||
|
if (!targetSel.value && !binSel.value) {
|
||||||
|
msg.textContent = 'A group rule needs a target or a boot binary.'; msg.className = 'msg err'; return;
|
||||||
|
}
|
||||||
|
const p = profileFields.read();
|
||||||
|
if (p.auto_hostname || p.auto_ip || p.unattended_file) {
|
||||||
|
msg.textContent = 'Auto-deploy fields are per-machine — clear them, or use a full MAC.'; msg.className = 'msg err'; return;
|
||||||
|
}
|
||||||
|
const cfg = await getJSON('/api/boot-rules').catch(() => ({rules: [], webhook_url: ''}));
|
||||||
|
(cfg.rules = cfg.rules || []).push({
|
||||||
|
mac_prefix: mac, arch: archSel.value, target: targetSel.value,
|
||||||
|
driver_mode: binSel.value, enabled: true, note: labelInput.value,
|
||||||
|
});
|
||||||
|
const r = await putJSON('/api/boot-rules', cfg);
|
||||||
|
if (r.ok) { msg.textContent = 'Group rule saved.'; msg.className = 'msg ok'; render('hosts'); }
|
||||||
|
else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
|
||||||
|
}}, 'Bind to target');
|
||||||
|
|
||||||
const rows = hosts.map(h => {
|
const rows = hosts.map(h => {
|
||||||
// v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole
|
// v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole
|
||||||
@@ -1194,23 +1476,38 @@
|
|||||||
el('div', {class:'card'}, [
|
el('div', {class:'card'}, [
|
||||||
el('header', {}, el('h2', {}, 'Pin MAC to boot target')),
|
el('header', {}, el('h2', {}, 'Pin MAC to boot target')),
|
||||||
el('div', {class:'body'}, [
|
el('div', {class:'body'}, [
|
||||||
el('div', {class:'form-row'}, [
|
el('div', {class:'form-row cols-3'}, [
|
||||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'MAC address'), macInput]),
|
el('label', {class:'field'}, [
|
||||||
|
el('span', {class:'name'}, 'MAC address or prefix'),
|
||||||
|
macInput,
|
||||||
|
el('span', {class:'hint'}, 'Full MAC pins one machine; a prefix (OUI) makes a group rule.'),
|
||||||
|
]),
|
||||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]),
|
el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]),
|
||||||
|
el('label', {class:'field'}, [
|
||||||
|
el('span', {class:'name'}, 'Architecture (optional)'),
|
||||||
|
archSel,
|
||||||
|
el('span', {class:'hint'}, 'Selecting one makes a group rule for that firmware.'),
|
||||||
|
]),
|
||||||
el('label', {class:'field', style:'grid-column:1 / -1'}, [
|
el('label', {class:'field', style:'grid-column:1 / -1'}, [
|
||||||
el('span', {class:'name'}, 'Target'),
|
el('span', {class:'name'}, 'Target'),
|
||||||
targetSel,
|
targetSel,
|
||||||
el('span', {class:'hint'},
|
el('span', {class:'hint'},
|
||||||
'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'),
|
'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'),
|
||||||
]),
|
]),
|
||||||
|
el('label', {class:'field'}, [
|
||||||
|
el('span', {class:'name'}, 'Boot binary (optional)'),
|
||||||
|
binSel,
|
||||||
|
el('span', {class:'hint'}, 'Pin Secure Boot racks to "shim" — zero failed boot cycles.'),
|
||||||
|
]),
|
||||||
]),
|
]),
|
||||||
el('div', {style:'margin-top:16px'}, profileFields.wrap),
|
el('div', {style:'margin-top:16px'}, profileFields.wrap),
|
||||||
upsertBtn, msg,
|
upsertBtn, msg,
|
||||||
el('p', {class:'msg', style:'margin-top:14px'},
|
el('p', {class:'msg', style:'margin-top:14px'},
|
||||||
'When a client with a bound MAC requests boot.ipxe, OpenPXE ' +
|
'When a matching client requests boot.ipxe, OpenPXE short-circuits ' +
|
||||||
'short-circuits past the interactive menu and chains directly. ' +
|
'past the interactive menu and chains directly. Decision order: ' +
|
||||||
'If an unattended file is selected, the matching kernel argument ' +
|
'exact MAC pin → first matching group rule → menu. ' +
|
||||||
'is injected and the hostname/IP are templated into the answer file.'),
|
'If an unattended file is selected on a pin, the matching kernel ' +
|
||||||
|
'argument is injected and the hostname/IP are templated into the answer file.'),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
el('div', {class:'card'}, [
|
el('div', {class:'card'}, [
|
||||||
@@ -1220,6 +1517,7 @@
|
|||||||
]),
|
]),
|
||||||
table,
|
table,
|
||||||
]),
|
]),
|
||||||
|
groupRulesCard(rulesCfg, reserved.concat(targets)),
|
||||||
el('div', {class:'card'}, [
|
el('div', {class:'card'}, [
|
||||||
el('header', {}, [
|
el('header', {}, [
|
||||||
el('h2', {}, 'Host log'),
|
el('h2', {}, 'Host log'),
|
||||||
@@ -1411,7 +1709,7 @@
|
|||||||
// beneath the input row instead of butting against the password
|
// beneath the input row instead of butting against the password
|
||||||
// fields. Mirrors the `Save SSO settings` button below for visual
|
// fields. Mirrors the `Save SSO settings` button below for visual
|
||||||
// parity between the two settings cards.
|
// parity between the two settings cards.
|
||||||
const accountSave = el('button', {style:'margin-top:6px', onclick: async () => {
|
const accountSave = el('button', {onclick: async () => {
|
||||||
accountMsg.textContent = ''; accountMsg.className = 'msg';
|
accountMsg.textContent = ''; accountMsg.className = 'msg';
|
||||||
if (!currentPw.value) {
|
if (!currentPw.value) {
|
||||||
accountMsg.textContent = 'Current password is required.';
|
accountMsg.textContent = 'Current password is required.';
|
||||||
@@ -1549,7 +1847,7 @@
|
|||||||
};
|
};
|
||||||
ssoMode.onchange = refreshSsoFields;
|
ssoMode.onchange = refreshSsoFields;
|
||||||
const ssoMsg = el('div', {class:'msg', style:'margin-top:8px'});
|
const ssoMsg = el('div', {class:'msg', style:'margin-top:8px'});
|
||||||
const ssoSave = el('button', {style:'margin-top:16px', onclick: async () => {
|
const ssoSave = el('button', {onclick: async () => {
|
||||||
ssoMsg.textContent = ''; ssoMsg.className = 'msg';
|
ssoMsg.textContent = ''; ssoMsg.className = 'msg';
|
||||||
const payload = {
|
const payload = {
|
||||||
enabled: ssoEnabled.checked,
|
enabled: ssoEnabled.checked,
|
||||||
@@ -1630,13 +1928,17 @@
|
|||||||
const logoMsg = el('div', {class:'msg', style:'margin-top:10px'});
|
const logoMsg = el('div', {class:'msg', style:'margin-top:10px'});
|
||||||
const bust = '?v=' + Date.now(); // bust the preview cache after a change
|
const bust = '?v=' + Date.now(); // bust the preview cache after a change
|
||||||
const brandingPresence = status.branding || { light:false, dark:false, client:false };
|
const brandingPresence = status.branding || { light:false, dark:false, client:false };
|
||||||
|
// Each swatch previews on a background matching where the mark
|
||||||
|
// lands (light page / dark page / dark PXE screen), independent of
|
||||||
|
// the operator's current page theme — so the Dark slot always reads
|
||||||
|
// as dark even while viewing Settings in light mode.
|
||||||
const slotDefs = [
|
const slotDefs = [
|
||||||
{ slot:'light', title:'Light mode', preview:'/assets/logo.svg?theme=light' + '&' + bust.slice(1),
|
{ slot:'light', title:'Light mode', preview:'/assets/logo.svg?theme=light' + '&' + bust.slice(1),
|
||||||
hint:'Shown on light-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' },
|
swatchBg:'#f4f5f7', hint:'Shown on light-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' },
|
||||||
{ slot:'dark', title:'Dark mode', preview:'/assets/logo.svg?theme=dark' + '&' + bust.slice(1),
|
{ slot:'dark', title:'Dark mode', preview:'/assets/logo.svg?theme=dark' + '&' + bust.slice(1),
|
||||||
hint:'Shown on dark-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' },
|
swatchBg:'#0e1014', hint:'Shown on dark-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' },
|
||||||
{ slot:'client', title:'Client', preview:'/branding/pxe-logo' + bust,
|
{ slot:'client', title:'Client', preview:'/branding/pxe-logo' + bust,
|
||||||
hint:'Above the PXE boot menu.', accept:'image/png,image/jpeg,image/webp,image/gif' },
|
swatchBg:'#0e1014', hint:'Above the PXE boot menu.', accept:'image/png,image/jpeg,image/webp,image/gif' },
|
||||||
];
|
];
|
||||||
const slotCol = (def) => {
|
const slotCol = (def) => {
|
||||||
const set = !!brandingPresence[def.slot];
|
const set = !!brandingPresence[def.slot];
|
||||||
@@ -1662,7 +1964,7 @@
|
|||||||
el('span', {class:'name'}, def.title),
|
el('span', {class:'name'}, def.title),
|
||||||
set ? el('span', {class:'tag ok'}, 'set') : el('span', {class:'tag'}, 'default'),
|
set ? el('span', {class:'tag ok'}, 'set') : el('span', {class:'tag'}, 'default'),
|
||||||
]),
|
]),
|
||||||
el('div', {class:'swatch', style: def.slot === 'light' ? 'background:#f4f5f7' : ''},
|
el('div', {class:'swatch', style:'background:' + def.swatchBg},
|
||||||
el('img', {src: def.preview, alt: def.title + ' logo'})),
|
el('img', {src: def.preview, alt: def.title + ' logo'})),
|
||||||
el('div', {class:'logo-slot-hint'}, def.hint),
|
el('div', {class:'logo-slot-hint'}, def.hint),
|
||||||
el('div', {style:'display:flex;gap:6px;flex-wrap:wrap'}, [
|
el('div', {style:'display:flex;gap:6px;flex-wrap:wrap'}, [
|
||||||
@@ -1795,13 +2097,13 @@
|
|||||||
smtp_implicit_tls: sTls.checked,
|
smtp_implicit_tls: sTls.checked,
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveBtn = el('button', {style:'margin-top:16px', onclick: async () => {
|
const saveBtn = el('button', {onclick: async () => {
|
||||||
nMsg.textContent = 'Saving…'; nMsg.className = 'msg';
|
nMsg.textContent = 'Saving…'; nMsg.className = 'msg';
|
||||||
const r = await putJSON('/api/notify', collectNotify());
|
const r = await putJSON('/api/notify', collectNotify());
|
||||||
if (r.ok) { nMsg.textContent = 'Saved.'; nMsg.className = 'msg ok'; render('settings'); }
|
if (r.ok) { nMsg.textContent = 'Saved.'; nMsg.className = 'msg ok'; render('settings'); }
|
||||||
else { nMsg.textContent = 'Save failed: ' + (await r.text()); nMsg.className = 'msg err'; }
|
else { nMsg.textContent = 'Save failed: ' + (await r.text()); nMsg.className = 'msg err'; }
|
||||||
}}, 'Save notification settings');
|
}}, 'Save notification settings');
|
||||||
const testBtn = el('button', {class:'ghost', style:'margin-top:16px;margin-left:8px',
|
const testBtn = el('button', {class:'ghost', style:'margin-left:8px',
|
||||||
onclick: async () => {
|
onclick: async () => {
|
||||||
nMsg.textContent = 'Sending test…'; nMsg.className = 'msg';
|
nMsg.textContent = 'Sending test…'; nMsg.className = 'msg';
|
||||||
// Save first so the test uses exactly what's on screen.
|
// Save first so the test uses exactly what's on screen.
|
||||||
@@ -1892,9 +2194,24 @@
|
|||||||
el('div', {class:'about-hero'}, [
|
el('div', {class:'about-hero'}, [
|
||||||
el('h2', {}, 'OpenPXE'),
|
el('h2', {}, 'OpenPXE'),
|
||||||
el('p', {class:'lead'},
|
el('p', {class:'lead'},
|
||||||
'Air-gapped network PXE boot, container-native, that anyone can run. ' +
|
'The network-boot platform for modern infrastructure. Drop in an ISO ' +
|
||||||
'No CDN calls, no telemetry, no surprise external dependencies — ship ' +
|
'and every machine on your network — BIOS, UEFI, Secure Boot — can ' +
|
||||||
'the image once, run it forever.'),
|
'boot it, image from it, and install unattended. One container, one ' +
|
||||||
|
'static binary, nothing installed on clients, nothing leaving your network.'),
|
||||||
|
el('div', {style:'display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:18px 0'}, [
|
||||||
|
['Boot anything', 'Linux, Windows, hypervisors, rescue tools — uploaded ' +
|
||||||
|
'ISOs become menu entries automatically, served on demand from local ' +
|
||||||
|
'disk or your existing NFS, SMB, or SFTP libraries.'],
|
||||||
|
['Adapt to every machine', 'Per-machine boot intelligence: firmware quirks, ' +
|
||||||
|
'NIC driver fallback, and a Microsoft-signed Secure Boot chain are ' +
|
||||||
|
'negotiated automatically and remembered — no toggles, no client prep.'],
|
||||||
|
['Run it in production', 'SAML single sign-on, token-scoped answer files, ' +
|
||||||
|
'fleet routing rules, Wake-on-LAN, queued mass deployment, Prometheus ' +
|
||||||
|
'metrics. Built in Rust for boot infrastructure that cannot flinch.'],
|
||||||
|
].map(([h, body]) => el('div', {}, [
|
||||||
|
el('h3', {style:'margin:0 0 6px;font-size:13.5px'}, h),
|
||||||
|
el('p', {class:'msg', style:'font-size:12px;margin:0'}, body),
|
||||||
|
]))),
|
||||||
el('div', {class:'who'}, [
|
el('div', {class:'who'}, [
|
||||||
el('span', {}, 'Developer: '), el('strong', {}, 'Miles Ward'), el('br'),
|
el('span', {}, 'Developer: '), el('strong', {}, 'Miles Ward'), el('br'),
|
||||||
el('span', {}, 'Version: '), el('strong', {}, status.version || '?'), el('br'),
|
el('span', {}, 'Version: '), el('strong', {}, status.version || '?'), el('br'),
|
||||||
@@ -1905,15 +2222,16 @@
|
|||||||
]),
|
]),
|
||||||
el('div', {style:'margin-top:18px'}, [updBtn, updMsg]),
|
el('div', {style:'margin-top:18px'}, [updBtn, updMsg]),
|
||||||
el('p', {class:'msg', style:'margin-top:18px'},
|
el('p', {class:'msg', style:'margin-top:18px'},
|
||||||
'iPXE is an internal implementation detail. Everything the firmware ' +
|
'Private by design: no telemetry, no CDN calls, no runtime ' +
|
||||||
'executes is generated from the settings on these tabs — there is no ' +
|
'dependencies on the outside world. Air-gapped labs, customer sites ' +
|
||||||
'hand-written .ipxe path anywhere in this product.'),
|
'without internet, and locked-down OpenShift clusters run the same ' +
|
||||||
|
'image, the same way, indefinitely.'),
|
||||||
el('p', {class:'msg'},
|
el('p', {class:'msg'},
|
||||||
'Vision: a deployment-grade tool that works on first try in the most ' +
|
'Principled by default: OpenPXE never asks an operator to install ' +
|
||||||
'awkward environments — air-gapped labs, customer sites without ' +
|
'test-signed drivers, modify a client’s trust store, or weaken ' +
|
||||||
'internet, OpenShift clusters with strict SCCs — without ever asking ' +
|
'Secure Boot. Everything the firmware executes is generated from the ' +
|
||||||
'an operator to install drivers signed with test certificates or to ' +
|
'settings on these tabs — there are no hand-written boot scripts to ' +
|
||||||
'flip "testsigning" on a target machine.'),
|
'maintain and no internals to learn.'),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -2137,7 +2455,9 @@
|
|||||||
// own self-contained <form>; when SSO is enabled, a distinct
|
// own self-contained <form>; when SSO is enabled, a distinct
|
||||||
// "Sign in with …" button sits below a divider — the credential
|
// "Sign in with …" button sits below a divider — the credential
|
||||||
// fields no longer double as the SSO trigger.
|
// fields no longer double as the SSO trigger.
|
||||||
const ssoLive = ssoConfig && ssoConfig.enabled && (ssoConfig.metadata_url || ssoConfig.metadata);
|
// `enabled` from /api/me already means "usable" (enabled AND a metadata
|
||||||
|
// source is configured), so the button only shows when SSO will work.
|
||||||
|
const ssoLive = !!(ssoConfig && ssoConfig.enabled);
|
||||||
const ssoBlock = ssoLive
|
const ssoBlock = ssoLive
|
||||||
? el('div', {class:'sso-block'}, [
|
? el('div', {class:'sso-block'}, [
|
||||||
el('div', {class:'auth-divider'}, el('span', {}, 'or')),
|
el('div', {class:'auth-divider'}, el('span', {}, 'or')),
|
||||||
@@ -2382,10 +2702,12 @@
|
|||||||
])));
|
])));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Preload the SSO config so the login card can offer the operator
|
// v0.5.9: the login card's "Sign in with …" button keys off the SSO
|
||||||
// an "Sign in with X" button when configured. Failure is harmless.
|
// descriptor that /api/me now carries (public, non-sensitive: enabled
|
||||||
try { ssoConfig = await fetch('/api/sso').then(r => r.ok ? r.json() : null); }
|
// + idp_name + idp_logo_url). It's available signed in or out, so the
|
||||||
catch { ssoConfig = null; }
|
// button is static — it no longer relied on the auth-gated /api/sso,
|
||||||
|
// which 401s pre-auth and made the button vanish on fresh login loads.
|
||||||
|
ssoConfig = me.sso || null;
|
||||||
|
|
||||||
if (me.setup_required) {
|
if (me.setup_required) {
|
||||||
showAuthScreen('setup');
|
showAuthScreen('setup');
|
||||||
|
|||||||
+39
-27
@@ -23,11 +23,19 @@ ARG RUST_VERSION=1.95
|
|||||||
# and serve as the baseline that the PNG-enabled x86_64/arm64 UEFI
|
# and serve as the baseline that the PNG-enabled x86_64/arm64 UEFI
|
||||||
# binaries from the `ipxe-build` stage overlay on top of.
|
# binaries from the `ipxe-build` stage overlay on top of.
|
||||||
FROM debian:12-slim AS fetch
|
FROM debian:12-slim AS fetch
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
# rpm2cpio + cpio: extract Fedora's Microsoft-signed shim/GRUB RPMs for
|
||||||
|
# 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 ##########
|
########## build PNG-enabled iPXE from source ##########
|
||||||
# v0.4.69: THE graphical-boot-menu unlock. iVentoy paints a PNG
|
# v0.4.69: THE graphical-boot-menu unlock. iVentoy paints a PNG
|
||||||
@@ -60,35 +68,41 @@ COPY deploy/ipxe/local/ deploy/ipxe/local/
|
|||||||
RUN mkdir -p assets/ipxe && bash scripts/build-ipxe.sh /src/assets/ipxe
|
RUN mkdir -p assets/ipxe && bash scripts/build-ipxe.sh /src/assets/ipxe
|
||||||
|
|
||||||
########## build openpxe ##########
|
########## build openpxe ##########
|
||||||
FROM rust:${RUST_VERSION}-bookworm AS build
|
# v0.5.2: cross-compile the Rust binary NATIVELY — no QEMU.
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
# v0.4.5: build a fully static musl binary (matches Bootimus v0.1.70's
|
|
||||||
# move). The resulting `/openpxe` has no glibc dependency at all, which:
|
|
||||||
# - Lets the runtime stage be any Linux distro (we still ship Debian
|
|
||||||
# slim for the `samba` / `wimtools` / `nfs-common` shellouts, but a
|
|
||||||
# scratch/distroless variant becomes a one-line swap).
|
|
||||||
# - Cuts a class of "GLIBC_2.39 not found" surprises when running on
|
|
||||||
# older RHEL/Rocky hosts that don't match Debian 12's libc version.
|
|
||||||
# - Sidesteps cross-compilation snags (the binary is its own world).
|
|
||||||
#
|
#
|
||||||
# x86_64-unknown-linux-musl is fully static by default (no extra
|
# This stage is pinned to $BUILDPLATFORM (the builder's native arch — arm64
|
||||||
# RUSTFLAGS needed). musl-tools provides the linker.
|
# 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
|
||||||
|
# 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 \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends musl-tools \
|
&& apt-get install -y --no-install-recommends python3 python3-pip \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& rustup target add x86_64-unknown-linux-musl
|
&& rustup target add x86_64-unknown-linux-musl \
|
||||||
|
&& pip3 install --no-cache-dir --break-system-packages ziglang \
|
||||||
|
&& cargo install --locked cargo-zigbuild
|
||||||
|
|
||||||
# Copy the whole workspace in one go. We used to do a two-pass "cache-prime
|
|
||||||
# with stubs, then real build" dance for dep-compile reuse; that turned out
|
|
||||||
# to silently serve stale stub binaries when cargo's fingerprint didn't
|
|
||||||
# notice the source swap. A single build is ~1.5 min longer on cold cache
|
|
||||||
# but guarantees the binary reflects the sources we copied.
|
|
||||||
# Do not copy rust-toolchain.toml into the image. The local workspace pins
|
# Do not copy rust-toolchain.toml into the image. The local workspace pins
|
||||||
# developer tooling, but inside Docker we intentionally use the Rust version
|
# developer tooling, but inside Docker we intentionally use the Rust version
|
||||||
# selected by the base image. Copying rust-toolchain.toml with
|
# selected by the base image. Copying rust-toolchain.toml with
|
||||||
# `channel = "stable"` makes rustup download a second full toolchain during
|
# `channel = "stable"` makes rustup download a second full toolchain during
|
||||||
# `cargo build`, which is slow and can exhaust small Colima/CI disks.
|
# the build, which is slow and can exhaust small Colima/CI disks.
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
COPY crates/ crates/
|
COPY crates/ crates/
|
||||||
# Baseline binaries (BIOS / i386 / wimboot), then overlay the
|
# Baseline binaries (BIOS / i386 / wimboot), then overlay the
|
||||||
@@ -100,13 +114,11 @@ 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/snponly.efi /src/assets/ipxe/snponly.efi
|
||||||
COPY --from=ipxe-build /src/assets/ipxe/ipxe.efi /src/assets/ipxe/ipxe.efi
|
COPY --from=ipxe-build /src/assets/ipxe/ipxe.efi /src/assets/ipxe/ipxe.efi
|
||||||
|
|
||||||
# Cache cargo registry + target across builds. The mtime touch is
|
# Cache cargo registry + target across builds. `cargo zigbuild` runs the
|
||||||
# belt-and-suspenders: cargo occasionally misses mtime-only changes on
|
# native rustc (fast) and links for x86_64-musl with zig — no emulation.
|
||||||
# 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 \
|
||||||
find crates -name '*.rs' -exec touch {} + && \
|
cargo zigbuild --release --target x86_64-unknown-linux-musl --bin openpxe && \
|
||||||
cargo build --release --target x86_64-unknown-linux-musl --bin openpxe && \
|
|
||||||
cp target/x86_64-unknown-linux-musl/release/openpxe /openpxe && \
|
cp target/x86_64-unknown-linux-musl/release/openpxe /openpxe && \
|
||||||
ls -l /openpxe
|
ls -l /openpxe
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OpenPXE">
|
||||||
|
<title>OpenPXE</title>
|
||||||
|
<!-- Static README mark: the "rainbow-horizon" medallion from the web UI,
|
||||||
|
with the SMIL animation removed so it renders reliably as an <img>
|
||||||
|
on Gitea/GitHub. -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="opxRainbow" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="#330f1f"/>
|
||||||
|
<stop offset="12.56%" stop-color="#c83228"/>
|
||||||
|
<stop offset="25.06%" stop-color="#fb8841"/>
|
||||||
|
<stop offset="37.56%" stop-color="#d3dd92"/>
|
||||||
|
<stop offset="50.06%" stop-color="#59824f"/>
|
||||||
|
<stop offset="62.06%" stop-color="#002414"/>
|
||||||
|
<stop offset="74.06%" stop-color="#00143d"/>
|
||||||
|
<stop offset="86.06%" stop-color="#2874d7"/>
|
||||||
|
<stop offset="100%" stop-color="#99c2ff"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="12" cy="12" r="10.5" fill="url(#opxRainbow)"
|
||||||
|
stroke="rgba(0,0,0,0.18)" stroke-width="0.6"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 984 B |
+23
-7
@@ -41,15 +41,31 @@ DEST="${1:-$ROOT/assets/ipxe}"
|
|||||||
WORK="$(mktemp -d)"
|
WORK="$(mktemp -d)"
|
||||||
trap 'rm -rf "$WORK"' EXIT
|
trap 'rm -rf "$WORK"' EXIT
|
||||||
|
|
||||||
# Pinned upstream iPXE. Rolling master is fine functionally, but a pin
|
# Pinned upstream iPXE. Rolling master is fine functionally, but a pin keeps
|
||||||
# keeps builds reproducible and protects against a transient master
|
# builds reproducible, protects against a transient master breakage, and —
|
||||||
# breakage. Bump deliberately.
|
# 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_REPO="https://github.com/ipxe/ipxe.git"
|
||||||
IPXE_REF="${IPXE_REF:-master}"
|
IPXE_REF="${IPXE_REF:-95ffbf4745553e8a207922389929e1943c0237c0}"
|
||||||
|
|
||||||
echo ">> cloning iPXE ($IPXE_REF)"
|
echo ">> fetching iPXE ($IPXE_REF)"
|
||||||
git clone --depth 1 --branch "$IPXE_REF" "$IPXE_REPO" "$WORK/ipxe" 2>/dev/null \
|
# Shallow-fetch the exact ref: works for a full commit SHA (GitHub allows
|
||||||
|| git clone "$IPXE_REPO" "$WORK/ipxe"
|
# 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"
|
SRC="$WORK/ipxe/src"
|
||||||
|
|
||||||
echo ">> applying OpenPXE config overrides (PNG + framebuffer + console cmd)"
|
echo ">> applying OpenPXE config overrides (PNG + framebuffer + console cmd)"
|
||||||
|
|||||||
@@ -29,11 +29,19 @@ 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"
|
||||||
"ipxe.efi=x86_64-efi/ipxe.efi" # fallback with bundled drivers
|
# DriverMode::Builtin (v0.6.1 automatic fallback) — iPXE's own all-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"
|
||||||
|
|||||||
Executable
+96
@@ -0,0 +1,96 @@
|
|||||||
|
#!/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
|
||||||
Reference in New Issue
Block a user