Compare commits

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 12:07:31 -04:00
Miles WardandClaude Opus 4.8 8dc526b53d v0.5.5: SFTP-over-SSH remote shares (russh, pure-Rust, ring backend)
Adds SFTP as a third remote ISO-library protocol alongside SMB and NFS.
Pure-Rust russh + russh-sftp on the ring crypto backend — no kernel
mount, no subprocess, no OpenSSL, no new C deps. Like NFS (and unlike
SMB), SFTP-sourced ISOs support HTTP Range requests because SFTP opens
a seekable file handle.

- iso-store: SftpShareManager (connect/auth/READDIR/seekable stream),
  IsoSource::Sftp, password OR SSH-key auth, trust-on-first-use host-key
  pinning, 0600 credential sidecar with a restart-safe derived path.
- http-api: /api/sftp-shares routes, Range-aware ISO dispatch arm,
  status/metrics counts, /api/docs entry, `sftp` terminal commands.
- webui: "SFTP (SSH)" protocol option with a password/key auth toggle,
  host-key fingerprint display, dashboard tile, updated copy.

SCP was deliberately rejected: sequential-only (no Range) and its crates
wrap libssh2 (C + OpenSSL), which would break the static-musl build.

russh is pinned to =0.55.0: russh 0.61 needs the stable RustCrypto
generation (pkcs8 0.11), which is API-incompatible with the release-
candidate crates bergshamra-crypto pins (pkcs8 =0.11.0-rc.11). 0.55 is
the newest russh on the prior generation (pkcs8 0.7) that coexists. Do
not bump past 0.55 until bergshamra adopts stable RustCrypto.

252 tests pass, clippy clean, static musl x86_64 binary (ring already
present via rustls + bergshamra, so no new crypto/C deps).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 11:49:18 -04:00
Miles WardandClaude Opus 4.8 e41b97c0bd v0.5.4: code-cleanup pass (AppError, figment config, encoding dedup, typed status, deps)
Final cleanup before hardware testing. No behaviour changes; 248 tests green,
clippy clean.

#1  AppError newtype (http-api/src/error.rs) with one IntoResponse mapping
    (NotFound→404, Invalid→400, _→500) + From<core::Error>/From<io::Error>.
    Converted the clearly-safe handlers (sso_put, unattended_upload,
    branding_clear) to `?`; intentionally left handlers with bespoke
    status semantics (Invalid→404 on category, 409 on duplicate share /
    open upload) explicit so no asserted status changes.
#2  figment-based Config::load (defaults → TOML → env). Keeps the historical
    flat OPENPXE_* names (Unraid/entrypoint compatible) AND adds the nested
    OPENPXE_SECTION__FIELD form; now covers every field (apply_env had
    silently skipped unattended_dir + bind addrs). 6 Jail tests prove
    backward-compat. Removed the hand-rolled apply_env.
#3  thiserror 1→2; dropped unused mime/mime_guess/once_cell deps.
#4  Re-evaluated: Duration::from_hours/from_mins are stable on the pinned
    1.95 toolchain and clippy prefers them — kept the readable form
    (the "unstable" premise didn't hold; MSRV is intentionally 1.95).
#5  insta snapshot of the rendered iPXE menu (version-filtered) + wiremock
    coverage of the SAML metadata-URL fetch (200 + non-2xx).
#6  api_status → typed StatusResponse struct (was a 25-key json! blob) with
    a full_flow guard test asserting every UI key + the started_at string
    shape. Deferred the /api/docs typed conversion (lowest value, highest
    churn, zero functional benefit).
#7  pct_encode/xml_escape de-duplicated into openpxe_core::encoding (were
    copied across app.rs + the SAML modules). No new crates.
#8  UploadSessions registry → parking_lot::RwLock (sync, never held across
    .await); per-session lock stays tokio::Mutex.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 03:33:05 -04:00
Miles WardandClaude Opus 4.8 1b4d07acd3 v0.5.3: dark-mode branding preview + unified button spacing
UI polish:
- Settings → Branding: each logo swatch now previews on a background
  matching where the mark lands (light page / dark page / dark PXE screen)
  regardless of the current page theme, so the Dark slot reads as dark
  even while viewing Settings in light mode.
- Site-wide button spacing: add one rule (`.card .body > button`) giving
  every primary card action button the same gap above it, and drop the
  ad-hoc per-button inline margins (14/16/6px) so the look is uniform.
  Fixes the Hosts → "Bind MAC to target" button butting against the form.

(Boot-menu highlight intentionally unchanged — a rotating-RGB highlight
isn't possible in iPXE's static single-draw menu; deferred to a future
custom-renderer effort.)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-03 02:50:51 -04:00
Miles WardandClaude Opus 4.8 dbb7aa10cc build: native arm64→x86_64-musl cross-compile (cargo-zigbuild), no QEMU
The Rust `build` stage previously ran the entire compiler under QEMU x86_64
emulation on the arm64 builder. That was ~15x slower (one crate took >20 min)
and the emulated gcc/linker intermittently SIGSEGV'd or hung mid-link
(observed again building v0.5.2).

Pin the stage to $BUILDPLATFORM (native arm64 on Apple Silicon, amd64 in CI)
and cross-compile to x86_64-unknown-linux-musl with cargo-zigbuild — zig cc
supplies the musl sysroot + linker. rustc runs natively; no emulation. Build
drops from ~30 min to a few minutes and is deterministic. Output is the same
fully static musl binary (verified: x86_64, not a dynamic executable, 0
OpenSSL strings).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-05-31 17:46:50 -04:00
Miles WardandClaude Opus 4.8 580ebf82d7 v0.5.2: FleetDM login split, 3-slot branding, unattended installs
Authentication / login:
- Separate the local username/password form from the SSO "Sign in with …"
  button (FleetDM-style divider + optional IdP logo); credential fields no
  longer double as the SSO trigger. Settings → SSO copy now says SAML is live.

Branding — three slots (light / dark / client) on one row:
- Light/Dark feed the top-left mark + sign-in page by active theme (with
  cross-theme fallback; theme toggle swaps the logo live). Client feeds the
  PXE boot-menu background. Favicon pinned to the bundled mark via a new
  /assets/favicon.svg endpoint. Legacy single logo migrates to dark + client.
- BrandingStore refactored to per-slot storage; /api/branding/logo/:slot.

Unattended installs (Storage → Advanced):
- New UnattendedStore (iso-store) + /api/unattended upload/list/delete and a
  public templated serve at /unattended/:id (+ NoCloud seed dir for
  autoinstall). Accepts .ks/.cfg/.seed/.yaml/.yml/.xml/user-data; classified
  on upload; stored in its own unattended/ dir, never the ISO listing/menu.
- {{HOSTNAME}}/{{IP}}/{{MAC}} substituted per host at serve time.

Host pins + Queue profiles:
- HostBinding + QueueEntry carry an optional DeployProfile (auto_hostname /
  auto_ip / unattended_file). Hosts pin form + a per-device Queue "Profile"
  button collect them. On boot, a matched MAC has the right kernel arg
  injected (inst.ks= / preseed url= / autoinstall ds=nocloud-net) and the
  hostname/IP templated into the served answer file. DHCP stays proxy-only.

Storage:
- Remote shares default protocol is now NFS; updated descriptive copy.

235 tests green, clippy clean. Still a single static musl binary, pure Rust.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-05-31 16:11:04 -04:00
Miles WardandClaude Opus 4.8 62acb264b3 feat(saml): wire SAML 2.0 SSO end-to-end (pure-Rust) + Settings/Storage UI consolidation (v0.5.1)
SAML SSO (the config was storage-only since v0.4.5; now it logs you in):
- New openpxe-core::saml — pure-Rust SP built on bergshamra (XML-DSig +
  exclusive c14n via RustCrypto, no OpenSSL/xmlsec/libxml2). The static
  musl binary stays C-free; samael was rejected for hard-requiring OpenSSL.
  * metadata.rs   — parse IdP EntityDescriptor (SSO URLs + signing certs),
                    build our SP metadata.
  * authn_request.rs — build + HTTP-Redirect-encode AuthnRequests.
  * response.rs   — verify the signature against the pinned IdP cert
                    (trusted_keys_only + strict_verification for XSW),
                    then enforce Status/Destination/Audience/time-bounds/
                    signature-scope. Stateless; returns the IDs the HTTP
                    layer needs.
- http-api saml_routes: GET /api/sso/login (302 to IdP), POST /api/sso/acs
  (verify -> InResponseTo correlation / IdP-initiated gating / assertion
  replay guard -> mint operator session -> 302), GET /api/sso/metadata.
  Added to the pre-auth allowlist; /api/sso config stays gated.
- SsoConfig gains entity_id (SP Entity ID, defaults to public base URL)
  and allow_idp_initiated (default off), mirroring FleetDM.
- Access model: any IdP-authenticated, cryptographically-verified user gets
  an operator session (single-tier; local admin remains the fallback owner).
- Login page: the "Sign in with <IdP>" button now drives the real flow and
  surfaces sso_error redirects.

UI consolidation:
- Removed the Advanced sidebar tab; folded its webhook-notifications +
  API-reference cards into a collapsible "Advanced" disclosure at the
  bottom of Settings.
- Merged the Storage tab's separate SMB and NFS cards into one "Remote
  shares" card with a protocol dropdown and a unified, protocol-badged
  table. No backend changes — same /api/smb-shares + /api/nfs-shares.

Tests: 17 SAML core tests (accept + reject tampered/unsigned/wrong-key/
wrong-audience/expired/future/wrong-issuer/non-success) and 6 ACS
integration tests (happy path, IdP-initiated gating, SP correlation,
replay, garbage). Full workspace: 206 tests green, clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-05-31 00:50:28 -04:00
Miles WardandClaude Opus 4.8 66ea6bbc40 docs: v0.5.1 design spec — SAML SSO wiring + Settings/Storage UI consolidation
Pure-Rust SAML SP (bergshamra), Advanced tab folded into Settings,
SMB+NFS merged into a Remote shares card with a protocol dropdown.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-05-31 00:10:40 -04:00
Miles WardandClaude Opus 4.8 93cd2a42a9 v0.5.0: fix update-check repository URL (inherit workspace repository)
The About-tab "check for updates" returned "repository URL not
configured at build time" because the http-api crate didn't inherit the
workspace `repository` field, leaving CARGO_PKG_REPOSITORY empty. Add
`repository.workspace = true` so the Gitea releases API URL derives
correctly, and strengthen the unit test to assert the URL is present.

Caught by the v0.5.0 container smoke test before publish.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-05-29 15:05:17 -04:00
Miles WardandClaude Opus 4.8 3cb651be65 v0.5.0: Wake-on-LAN, webhook notifications, Advanced tab, login logo, update check
Closes the v0.4.x chapter — NFS works end to end. Five additions:

## Wake-on-LAN (Hosts → Bound hosts)
- New core::wol module: parse any MAC form, build the 102-byte magic
  packet, broadcast it. No special capability needed (ephemeral source
  port; SO_BROADCAST). Sends to the limited broadcast (255.255.255.255)
  AND the server's own subnet broadcast (computed from advertised IP +
  detected mask) so it reaches the right VLAN.
- POST /api/hosts/:mac/wol — only fires for *bound* MACs (404 otherwise)
  so it's not an open packet sprayer.
- Bound-hosts table grows a "Wake" button with inline Waking…/Sent ✓
  state.

## Webhook notifications (Advanced tab)
- core::notify: NotifyConfig + NotifyStore (notify.json), one provider
  at a time — Slack / Discord / Teams (incoming-webhook JSON) or SMTP.
  SMTP password is persisted but redacted on GET behind a __keep__
  sentinel the UI round-trips so the secret never leaves the box.
- http-api::notify: delivery — reqwest POST for chat (provider-shaped
  bodies), lettre for SMTP (rustls, STARTTLS/implicit TLS, no plaintext).
  10s timeout; every send is best-effort.
- GET/PUT /api/notify, POST /api/notify/test.
- Fired fire-and-forget on the canonical "machine is imaging" boot event
  and on WoL — never blocks the boot path.

## UI: Advanced tab
- New nav item. Holds the webhook config card and the API reference
  block (relocated from the bottom of Settings).

## UI: login/setup logo (FleetDM treatment)
- /api/me now returns has_custom_logo + logo_rev (public bootstrap).
  The login, setup, and connection-error cards render the uploaded logo
  full-width with the "OpenPXE" wordmark dropped — matching the sidebar.

## About: update check + licenses
- "Check for updates" button → GET /api/updates/check queries the Gitea
  releases API (derived from CARGO_PKG_REPOSITORY) and compares to the
  running version. Strictly on-demand — no background polling, keeps the
  air-gapped promise.
- License card documents the MIT OR Apache-2.0 dual license with links,
  plus a note on bundled components (iPXE GPLv2/UBDL, samba, wimtools).

Deps: lettre (SMTP, rustls) + reqwest gains the json feature. Both
rustls so the static musl binary stays OpenSSL-free.

Tests: 179 passing (+notify round-trip/redaction, webhook validation,
WoL-unbound-404, WoL packet loopback, version-compare). clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-05-29 14:34:20 -04:00
Miles WardandClaude Opus 4.8 f6eddd59f8 v0.4.69: PNG boot-menu background (iPXE built from source), NFS AUTH_SYS, FleetDM logo
Three things, headlined by the long-blocked graphical PXE menu.

## 1. Graphical PXE boot background — the iVentoy feature, finally

iVentoy paints a PNG background on the PXE screen using stock iPXE
built with CONSOLE_FRAMEBUFFER + IMAGE_PNG + CONSOLE_CMD; the public
iPXE binaries omit those, so `console --picture` is a no-op on them.
We now build our own iPXE from upstream with that thin config delta
(deploy/ipxe/local/{general,console}.h).

The 8-release blocker was cc1 segfaulting when an amd64 gcc ran under
QEMU emulation on the arm64 build host. Fix: a new `ipxe-build`
Dockerfile stage pinned to $BUILDPLATFORM (native arch — no emulation)
that cross-compiles x86_64 iPXE with CROSS_COMPILE=x86_64-linux-gnu-.
The compiler runs native and emits x86_64. Validated end-to-end:
png.o + fbcon.o + pixbuf.o all compile and link (confirmed via the
linked-ELF symbol table, not just strings), ~112s, no segfault. Host
tools needed libc6-dev (dropped by --no-install-recommends; without
it the native host compile falls through to iPXE's freestanding
headers and dies on bits/stdint.h — fixed).

Server side:
- pxe_logo.rs is now a full-screen background compositor: a dark field
  (matching the WebUI theme) with the operator's uploaded logo across
  the top, or — with no upload — a default OpenPXE rainbow disc drawn
  with pure pixel math (no font/SVG deps). Always 1024x768 (iPXE
  doesn't scale; this is the universal mode). WebP/JPEG/GIF/PNG in,
  PNG out (iPXE only eats PNG).
- /branding/pxe-logo always returns a PNG now (default when no logo,
  default when SVG) so the menu always has a background.
- render_menu uses `console --picture … --top 290 || console`: paints
  the background and reserves the logo band on PNG-capable binaries
  (x86_64 UEFI), cleanly falls back to text on the others. The ASCII
  wordmark is GONE.

Only x86_64 UEFI is built from source (host-arch-agnostic cross build);
BIOS/i386/arm64 keep upstream-fetched no-PNG binaries + text fallback.
Modern clients are overwhelmingly x86_64 UEFI.

## 2. NFS AUTH_SYS credential — fixes NFS3ERR_ACCES

v0.4.68's privileged-port fix got past MNT3ERR_ACCES (mount); operators
then hit NFS3ERR_ACCES on READDIR because nfs3_client defaults to
AUTH_NONE and virtually every server exports sec=sys. We now present an
AUTH_UNIX credential (uid 0 / gid 0): no_root_squash servers treat us
as root, root_squash servers map us to anon which reads any
world-readable ISO share. Kept fixed (no UI knob) to stay dead-simple.
Hint updated: a remaining NFS3ERR_ACCES is now a server-side
permission/squash issue, not IP/auth-flavor.

## 3. FleetDM-style full-width logo (top-left)

When a custom logo is uploaded the sidebar header drops the bundled
mark + "OpenPXE" wordmark and lets the logo span the header
(left-aligned, capped 200x50, contain). Rendered server-side via a
brand-class in index_html (has_custom_logo) so there's no flash of the
default. The bundled-default case is unchanged.

Tests: 164 passing. clippy -D warnings clean. iPXE build stage
validated in isolation before the full image build.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-05-29 03:11:35 -04:00
Miles WardandClaude Opus 4.8 1cca3e967c v0.4.68: fix NFS secure-export mount, logo cache-bust, dashboard disk card, NFS form spacing
Four operator-reported issues from v0.4.67 validation.

## 1. NFS MNT3ERR_ACCES even with the host IP allow-listed

Root cause: Linux kernel nfsd (what UniFi UNAS / Synology / TrueNAS all
run underneath) exports with the `secure` option by default, which only
accepts mount/NFS requests from a privileged source port (<1024). v0.4.67
explicitly connected from a non-privileged port on the mistaken assumption
that uid 10001 can't bind low ports — but the binary carries
CAP_NET_BIND_SERVICE (granted via setcap for the DHCP/TFTP/HTTP low-port
binds), which also covers privileged *source* ports for outbound connects.

Fix: build_connection now tries a privileged source port first (the common
case for every appliance NAS), then falls back to a non-privileged port
for `insecure` exports or capability-less environments. Each attempt has
its own connect timeout; a timeout on the first attempt skips the fallback
(the server isn't answering — a retry would just double the wait).

Also: hint_for now recognizes MNT3ERR_ACCES distinctly from NFS3ERR_ACCES
and explains both the allow-list and the secure/insecure angle, with the
UniFi /var/nfs/shared/<share> path convention called out.

## 2. Custom logo didn't update the top-left brand mark

The brand <img> and favicon were pinned to ?v=<app-version>, which only
changes on upgrade — so uploading a new logo left the cached bundled SVG
in place. Added a monotonic `rev` counter to BrandingStore that bumps on
every set/clear, persisted across restarts, surfaced through index_html as
an extra &r=<rev> cache-bust token on the brand mark + favicon URLs. Since
index.html is served no-cache, the fresh token lands on the next reload
after upload and the new logo appears immediately.

(Note: this updates the WebUI brand mark. The PXE *boot menu* still shows
the ASCII wordmark — painting the operator's PNG there needs the
IMAGE_PNG-enabled iPXE rebuild that remains queued for native x86_64
hardware. The /branding/pxe-logo compositor is ready for when it lands.)

## 3. Disk-space card on the Dashboard

Extracted the Storage tab's disk card into a shared diskSpaceCard(disk)
helper and added it to the Dashboard grid under the stat strip. Dashboard
fetches /api/storage/disk with the same graceful-degradation fallback the
Storage tab uses.

## 4. NFS "Add share" button touching the form field

The NFS card has a single form row (vs SMB's two), so the button butted
right against it. Added margin-top:14px to match SMB's effective spacing.

Tests: 162 passing (+2 — logo_rev bump, MNT3ERR_ACCES hint). clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-05-28 21:34:41 -04:00
Miles WardandClaude Opus 4.7 59bfdb3984 v0.4.67: NFSv3 alongside SMB (in-process via nfs3_client crate)
NFS is back — done right this time. v0.4.67 ships a pure-Rust NFSv3
client (`nfs3_client` 0.9 from the xetdata/Vaiz crate family) running
in-process inside the openpxe binary. No `mount.nfs`, no kernel
modules, no `CAP_SYS_ADMIN`, no subprocess. Works in every container
that the v0.4.65 SMB path works in (Unraid included).

The v0.4.65 SMB path stays as-is. Operators get both protocols
side-by-side and pick whichever their NAS prefers — or use both
together. NFSv3 has one architectural advantage over the SMB
userspace path: HTTP Range requests work for NFS-sourced ISOs
because NFSv3 READ3 takes an explicit offset. SMB-sourced ISOs still
return 416 for ranges (smbclient CLI can't seek mid-stream).

## What's new

- `crates/iso-store/src/nfs_share.rs` — `NfsShareManager` mirroring
  `SmbShareManager` structurally. Lists ISOs via READDIR3+LOOKUP3+
  GETATTR3, streams files via READ3 in 64 KiB chunks piped to axum
  body streams. Uses `connect_from_privileged_port(false)` because
  the openpxe binary runs as uid 10001 — most modern NFS servers
  allow that; a server that demands privileged ports needs
  `insecure` in /etc/exports, and the hint translation calls that
  out specifically.
- `IsoSource::Nfs { share_id, relative_path }` variant alongside the
  existing `Smb`. `IsoStore::iso_path_for` returns None for both;
  the HTTP handler dispatches to the right share manager.
- `/api/nfs-shares` CRUD + scan endpoints, parallel to
  `/api/smb-shares`. `POST` body: `{ server, export, port? }`.
- `nfs` terminal command back (this time as in-process, not kernel
  mount): `list | add <srv>:<export> [port] | remove | scan`. The
  v0.4.64 `nfs` command name pointing at kernel mount is moot
  history — same name, completely different mechanism.
- Storage tab: a new NFS shares card sits directly below the SMB
  shares card. The form is simpler (no auth fields) since NFSv3
  uses AUTH_SYS and access is gated server-side by client IP.
- Dashboard "Images available" tile sums SMB + NFS reachable shares
  into a generic "N remote shares" line.

## What's the same

- The structured `{error, stderr, hint}` JSON shape on failures
  matches the SMB API exactly, so the UI's error banner renders
  identically.
- Hint translation: NFS3ERR_ACCES → "exports list", NFS3ERR_NOENT →
  "export path doesn't exist", `mount denied` → "/etc/exports may
  need `insecure`", timeouts → "check IP/port/firewall".
- Persistence: `<work_dir>/nfs_shares.json`. No conflict with the
  long-dead v0.4.64 `nfs.json`.

## Why nfs3_client

User picked it: pure-Rust matches the architecture, NFSv3 covers the
real-world cases, AUTH_SYS keeps the UI simple. The crate is at
0.9.0, MIT/Unlicense, rust-version 1.88 (we're on 1.95). Tokio
feature flag enabled. Image size unchanged at compile time — single
musl static binary, no extra OS packages.

## Tests

160 passing (was 150 in v0.4.66, +10):
- nfs_share parser: stable share ids, server normalization (smb://,
  cifs://, \\, // all stripped).
- hint_for(): NFS3ERR_ACCES, NFS3ERR_NOENT, mount denied, unknown.
- status_label() covers the common nfsstat3 codes.
- HTTP integration: nfs-shares list starts empty, missing server
  rejected, export without leading slash rejected.

`cargo clippy --workspace --all-targets -- -D warnings` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-28 12:56:46 -04:00
Miles WardandClaude Opus 4.7 2ddf424959 v0.4.66: ship smbclient in the runtime image
v0.4.65 added the SmbShareManager but the Dockerfile only installed
the `samba` package — in Debian 12 that ships the SERVER (smbd) only,
not the `smbclient` CLI the new manager shells out to. Every "Add
share" attempt surfaced:

    could not exec smbclient: No such file or directory (os error 2)

Fix is two lines: add `smbclient` to the runtime apt install, drop
the leftover `nfs-common` (no kernel-mount NFS anymore so the helpers
aren't needed).

While in the area, harden the manager so future stripped-down runtime
images get a useful error instead of a bare exec failure:

- `list_isos` and `stream_iso` both detect `ErrorKind::NotFound` on
  spawn and emit "smbclient binary not found on $PATH".
- `hint_for` translates the missing-binary pattern into an actionable
  hint: "pull OpenPXE v0.4.66+ or add the Debian `smbclient` package
  to your runtime stage." So even on a custom build the UI still
  surfaces a clear remediation.

Tests: 150 passing (+1 for the new hint). clippy clean.

The image is still ~98 MB — `smbclient` adds <1 MB on top of the
already-installed samba server.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-28 11:58:51 -04:00
Miles WardandClaude Opus 4.7 062b1497d4 v0.4.65: swap kernel-mount NFS for userspace SMB (smbclient)
v0.4.64's NFS path didn't work on Unraid even with --privileged
because Unraid's base kernel ships without the nfs/nfsv4 client
modules — and no container-side configuration can load a host kernel
module. SMB has the same kernel-mount problem (`mount -t cifs` needs
the cifs module) but it also has a usable *userspace* client: Samba's
`smbclient` CLI, which speaks the SMB protocol over a plain TCP socket
with no kernel involvement. This is the same approach Bootimus uses,
and works in every container regardless of host kernel modules or
container capabilities.

What's gone:

* `crates/iso-store/src/nfs.rs` (in entirety)
* `NfsManager`, `NfsMount`, `NfsAddRequest`, `NfsVersion` types
* `IsoSource::Nfs` variant
* `IsoStore::nfs_root` / `IsoStore::set_nfs_root`
* `/api/nfs`, `/api/nfs/:id`, `/api/nfs/:id/scan` routes
* `nfs` terminal command
* Storage tab's NFS shares card and the v0.4.64 fstab-options
  diagnostics work (the whole error path is moot now)

What's new:

* `crates/iso-store/src/smb_share.rs` — `SmbShareManager` that drives
  `smbclient` as a subprocess. Indexes shares via `smbclient -c "ls
  *.iso"` and streams files via `smbclient -c "get file -"` piped
  straight into HTTP response bodies. No local cache, no double disk
  usage.
* `IsoSource::Smb { share_id, relative_path }` variant.
* `IsoStore::iso_path_for` returns None for SMB sources — the HTTP
  ISO download handler dispatches on the source kind and streams via
  the SmbShareManager when it's SMB.
* `/api/smb-shares` + `/api/smb-shares/:id` + `/api/smb-shares/:id/scan`
  routes.
* `share` terminal command (`list | add //srv/share [auth] | remove |
  scan`). Auth spec is `guest` or `user:password`.
* Storage tab: SMB shares card replaces the NFS one. Two-column form
  for server + share name, three-column form for guest checkbox /
  username / password. Username and password fields auto-disable when
  Guest is checked.
* Credentials live under <work_dir>/smb_creds/<id>.cred at 0600
  permissions so they don't leak through `ps`. Persisted state at
  <work_dir>/smb_shares.json (sans password — re-entered on add /
  re-scan).

Why subprocess and not a Rust crate:

* The Debian runtime image already ships the `samba` package
  (Dockerfile line 84) — `smbclient` is right there.
* Library options (pavao, etc.) wrap libsmbclient so they still pull
  in the same C library at runtime.
* Subprocess gives operators a verifiable mental model — anything
  OpenPXE can do over SMB, they can reproduce by running `smbclient`
  manually at a shell.

Range-request limitation, called out in the smb_share.rs module docs
and the UI explainer: `smbclient -c 'get file -'` is a sequential
whole-file stream. HTTP range requests on SMB-sourced ISOs return
416. PXE workloads (iPXE chain, casper sanboot, wimboot) do
whole-file sequential reads, so this works in practice. A follow-up
release can add libsmbclient-based seek if a real workload needs it.

Stderr-to-hint translation patterns mirror v0.4.64's NFS work:
NT_STATUS_LOGON_FAILURE → "check credentials", BAD_NETWORK_NAME →
"check share name", connection refused / timeout → "verify
reachability + firewall", etc. UI renders the raw smbclient error
plus the hint as two lines.

Tests (149 total, was 142 in v0.4.64):
* smb_share parser tests covering ISO + skipped directory, filenames
  with spaces, non-ISO filtering.
* hint_for() translation tests for the dominant NT_STATUS codes.
* Server normalization (smb://, cifs://, \\, // prefixes all stripped).
* HTTP integration: shares list starts empty, invalid server / missing
  username / path in share name all rejected with actionable hints.

`cargo clippy --workspace --all-targets -- -D warnings` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-28 11:19:47 -04:00
Miles Ward de23a2be33 Revert "v0.4.65: Local directory ISO source (bind-mount workaround for Unraid)"
This reverts commit 72a2089c98.
2026-05-28 10:47:17 -04:00
Miles WardandClaude Opus 4.7 72a2089c98 v0.4.65: Local directory ISO source (bind-mount workaround for Unraid)
Field report: even with CAP_SYS_ADMIN and full --privileged, NFS mounts
inside the OpenPXE container fail on Unraid with the same
"failed to apply fstab options" error v0.4.64 added diagnostics for.
The root cause is the host kernel: Unraid's base kernel ships without
the nfs/nfsv4 client modules loaded. Capabilities are necessary but
not sufficient; the modules have to be present on the host kernel for
in-container mount(2) to do anything. No container-side change can
fix that.

This is exactly the case every other PXE/imaging tool sidesteps
(Bootimus uses SMB; iVentoy, FOG, MAAS, Cobbler all rely on the host
to mount network storage and bind-mount the path into the imaging
service). v0.4.65 brings OpenPXE in line with that pattern.

What's new:

* `IsoSource::LocalDir { dir_id, relative_path }` — third source kind
  alongside `Local` (uploaded) and `Nfs` (in-container mount).
* `LocalDirManager` (crates/iso-store/src/local_dir.rs) — registers
  bind-mounted directories, validates them (absolute path, exists, is
  a directory, readable), scans for *.iso files, registers them with
  IsoStore. Persisted to <work_dir>/local_dirs.json so the relationship
  survives restarts.
* `NfsHostCaps::detect()` — pure read of /proc/filesystems on startup.
  Surfaced via GET /api/nfs/capabilities and used by the Storage tab to
  show a prominent red banner above the NFS form when in-container
  mounts cannot possibly work, pointing the operator at the Local
  Directories card as the recommended path.
* Four new API routes:
    GET    /api/nfs/capabilities
    GET    /api/local-dirs
    POST   /api/local-dirs           { path, label? }
    DELETE /api/local-dirs/:id
    POST   /api/local-dirs/:id/scan

UI changes (crates/webui/src/app.js):
* Storage tab: new "Local directories" card under the NFS card with
  the bind-mount form, an explainer paragraph (with the Docker
  `-v /mnt/user/isos:/mnt/external-isos` command), and the list of
  registered directories with rescan + remove actions.
* When NFS host caps are unavailable, the NFS card sprouts a red
  banner explaining what's wrong and pointing at the local-dir
  workaround. The card sub-header also flips to "N registered ·
  recommended on this host".
* ISO table: new "dir:<id>" source badge; on-disk ISOs show "on disk"
  in the actions column instead of a delete button (same pattern as
  NFS — OpenPXE doesn't own those bytes).
* API reference table picks up the four new endpoints + a hint about
  the new `port` field on NFS add.

Tests (+12, total 162):
* iso-store: 7 local_dir unit tests covering relative-path rejection,
  missing path, non-directory file, empty-directory success, default
  label, idempotent re-add, remove + iso-path-resolution clear.
* iso-store: 1 nfs unit test confirming NfsHostCaps::detect() never
  panics and the boolean accessors are consistent.
* http-api: 4 integration tests covering /api/nfs/capabilities,
  /api/local-dirs list/add/remove + relative-path 400.

`cargo clippy --workspace --all-targets -- -D warnings` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-28 03:09:43 -04:00
Miles WardandClaude Opus 4.7 a7a439a410 v0.4.64: NFS mount diagnostics — pre-flight probe, retry, hint translation
The dominant field failure from v0.4.63 was "mount.nfs: failed to apply
fstab options" (exit 32), surfaced verbatim by the Storage tab. The
message is misleading — it has nothing to do with /etc/fstab; it comes
from nfs-utils 2.6.x's nfs_options2string() and most commonly indicates
the container is missing CAP_SYS_ADMIN, /etc/mtab is unwritable, or an
auxiliary option triggered an option-transform edge case.

Backend (crates/iso-store/src/nfs.rs):
- TCP pre-flight probe to server:port (4s timeout) before shelling out.
  Catches wrong-IP / firewall cases as "cannot reach NFS port" instead
  of letting mount.nfs spit out an unhelpful message.
- proto=tcp explicit on NFSv3 (UDP is widely deprecated, modern NAS
  appliances often don't bind UDP at all).
- Optional `port` field on NfsAddRequest (defaults to 2049), persisted
  on NfsMount.
- On "failed to apply fstab options" / "internal option parsing error"
  retry with a minimal option set (vers=N,ro/rw only) — bypasses the
  nfs-utils transformation bug; if it still fails we get a real kernel
  error to translate.
- hint_for() translates well-known stderr patterns into actionable
  guidance — CAP_SYS_ADMIN for option-transform failures, exports-table
  for access-denied, export-path hint for "no such file or directory"
  (calling out the UniFi UNAS Pro /var/nfs/shared/<name> convention),
  etc.
- normalize_server() strips http://, https://, nfs:// schemes the
  operator may have pasted by mistake, plus trailing slashes.

API (crates/http-api/src/app.rs):
- api_nfs_add now returns a structured {error, stderr, hint} JSON body
  on failure instead of plain text. UI renders the error in bold with
  the hint as a dimmer second line.

UI (crates/webui/src/app.js):
- Storage tab's "Mount failed" banner now shows the raw error + hint on
  two lines. Each persisted mount row also surfaces last_hint under
  last_error.

Terminal (crates/http-api/src/terminal.rs):
- `nfs mount` command prints "hint: ..." on a follow-up line when the
  manager returns one.

Tests:
- 8 new tests covering option string (incl. proto=tcp on v3, port=N for
  non-default), minimal-options stripping, server normalization, and
  hint translation for each well-known stderr pattern.
- All 150 tests pass; clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-27 13:53:15 -04:00
Miles WardandClaude Opus 4.7 bd791462be v0.4.63: SSO row alignment, themed checkbox, dropdown affordance
Three UI nits the operator caught on v0.4.62, plus the queued PXE-theme
research note for the next release.

- SSO header grid is now a 4-column form-row matching the Administrator
  account card column-for-column (display name / logo URL / metadata
  source / metadata URL). Switching to XML mode collapses column 4 and
  drops the multi-line textarea on its own full-width row below.
- Native form chrome (checkboxes, scroll bars) follows the active
  OpenPXE theme via CSS `color-scheme`; the inline meta tag was forcing
  dark form controls in light mode, which is why the "Enable single
  sign-on" checkbox rendered as an opaque black square against the
  light panel.
- Checkbox itself is now custom-styled (16x16 rounded square, accent
  fill + tick on :checked) so the chrome reads identically across both
  palettes and browsers, not just on whichever WebKit happens to honor
  `accent-color`.
- <select> dropdowns get a hand-drawn chevron via background-image SVG;
  with `-webkit-appearance: none` the native arrow had disappeared,
  making "Metadata source" look squished next to the inputs beside it.
- Update credentials + Save SSO settings buttons get explicit top
  margins so they sit clearly under their input rows instead of butting
  against the field beneath.
- `docs/queued/ipxe-pxe-menu-theme-research.md` captures findings on
  how iVentoy paints its boot menu (iPXE `console --picture` with
  baked-in per-resolution PNGs, no EDID auto-detect) and the
  recommended Rust architecture for the follow-up release.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-26 02:32:38 -04:00
Miles WardandClaude Opus 4.7 89c02810c9 v0.4.62: ship the v0.4.61 cache fix as a buildable image
v0.4.61 source landed in main with the cache fix and the
PXE-logo compositor, plus an aspirational Dockerfile stage that
rebuilds iPXE from source with IMAGE_PNG enabled. The Dockerfile
stage hits intermittent `cc1: internal compiler error: Segmentation
fault` when cross-emulating x86_64 gcc under QEMU on arm64 build
hosts, which is what the build host I was using does. No v0.4.61
image was ever published as a result.

v0.4.62 walks back the iPXE-from-source change and ships a working
image with the same cache fix and the same compositor code in place.
The iPXE rebuild is queued for a follow-up release, to be built and
validated on the actual x86_64 Unraid hardware where the QEMU
instability doesn't apply.

What's in v0.4.62 vs v0.4.6:

- Asset URL versioning: index.html now appends `?v=<openpxe-version>`
  to every asset URL (app.css, app.js, logo.svg). Combined with
  `Cache-Control: no-cache, must-revalidate` on the asset handlers,
  upgrades land in operators' browsers without a hard refresh. This
  is the fix for "I pulled v0.4.6 but the UI still looks like v0.4.5".
- New PXE-logo compositor in iso-store::pxe_logo: decodes any raster
  the operator uploads, scales-to-fit into a 600×200 bounding box,
  pastes it centered at the top of a 1024×768 PNG canvas, and serves
  the result at GET /branding/pxe-logo. Wired into render_menu's
  `console --picture` directive; takes effect when the shipped iPXE
  binaries grow PNG support.
- ASCII OpenPXE wordmark in render_menu retained for v0.4.62 — works
  on the boot.ipxe.org pre-builds we currently ship.

Quality:
- 142 tests passing.
- cargo clippy --workspace --all-targets clean.
- No image dependency change since v0.4.61 (the `image = "0.25"` dep
  added in v0.4.61 stays — it backs the compositor).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-26 00:53:30 -04:00
Miles WardandClaude Opus 4.7 eb3b191a71 v0.4.61: asset cache fix, PNG-enabled iPXE, composed PXE logo
Two real issues v0.4.6 left on the table:

Asset caching:
- index.html now interpolates the running OpenPXE version into every
  asset URL as `?v=<version>` (app.css, app.js, logo.svg). Combined
  with `Cache-Control: no-cache, must-revalidate` on the asset
  handlers, browsers and intermediary proxies are forced to fetch
  fresh on every upgrade. Without this, last release's bundled JS
  kept serving the old UI even after the operator pulled the new
  image — invisible to anyone who only checks the version chip in
  the footer (which is dynamic).
- The Cache-Control header is also applied to logo.svg and loader.svg
  so a logo upload reflects immediately rather than after a hard
  refresh.

Real-image PXE menu logo (matches iVentoy now):
- New Dockerfile stage `ipxe-build` clones the iPXE source and
  compiles all four binaries (undionly.kpxe, snponly.efi for
  x86_64/i386, snponly.efi for arm64 via gcc-aarch64-linux-gnu) with
  IMAGE_PNG + CONSOLE_FRAMEBUFFER + CONSOLE_VESAFB enabled. Replaces
  the boot.ipxe.org fetch — those binaries are built without PNG
  support, which is why v0.4.6's `console --picture` line silently
  no-op'd.
- `iso-store::pxe_logo::compose_pxe_logo` decodes any operator upload
  (PNG / JPEG / WebP / GIF), downscales-to-fit if larger than
  600×200, and pastes it onto a transparent 1024×768 canvas
  centered horizontally with a 64-pixel top margin. iPXE paints the
  result at 1:1 on the typical VESA framebuffer, giving the
  iVentoy-style centered-logo look regardless of the operator's
  source dimensions.
- GET /branding/pxe-logo now returns the composed PNG. wimboot still
  fetches from ipxe/wimboot's GitHub release (separately signed).
- Dropped the ASCII OpenPXE wordmark from render_menu — once the
  real image paints, the banner would duplicate it visually. iPXE
  builds without PNG (none of ours after this release, but a third-
  party undionly might) simply show the menu without a logo, which
  is the right graceful-degradation outcome.

Quality:
- 142 tests passing (was 138 in v0.4.6): +4 pxe_logo unit tests
  covering canvas dimensions, centered-top placement, oversize
  downscale, and unsupported-bytes error handling; existing
  integration tests updated to verify the 1024×768 IHDR header from
  the composed PNG instead of round-tripping the raw upload.
- cargo clippy --workspace --all-targets clean.
- Image dependency: `image = "0.25"` with only `png/jpeg/webp/gif`
  features enabled. No new transitive C deps.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-26 00:38:39 -04:00
Miles WardandClaude Opus 4.7 f8bfab3823 v0.4.6: iVentoy-style PXE menu, top-right user menu, Settings touchups
PXE boot menu polish (iVentoy-inspired):
- render_menu now opens with a best-effort `console --picture
  <base>/branding/pxe-logo || console` line so iPXE builds with PNG
  support paint the operator's uploaded raster logo as the background.
- ASCII OpenPXE wordmark banner sits at the top of the menu in
  `item --gap` lines — always visible on every iPXE build, including
  the snponly/undionly variants without graphics console.
- New footer line above `choose`: "OpenPXE v0.4.6 - <arch label>",
  where <arch label> is mapped from iPXE's ${buildarch}/${platform}
  to "x86 BIOS", "x86_64 UEFI", or "arm64 UEFI". No URL, per brief.
- New GET /branding/pxe-logo route serves the operator's PNG / JPEG /
  WebP / GIF as-is for iPXE to consume. SVG uploads 404 here (iPXE
  can't rasterize SVG) — the always-visible ASCII wordmark stands in.
  Route stays public after admin setup so iPXE clients (no cookies)
  can fetch it.

UI:
- Removed the bottom-left "signed in as / Sign out" row.
- Added a person-icon button next to the theme toggle in the topbar.
  Click opens a small popover with: Name (display only), Edit account
  (jumps to Settings), Sign out. Esc + click-outside close it.
- Settings → Account card form chrome made consistent. The previous
  `label.field` selector only styled type=text/number, leaving
  password inputs with default browser chrome. Switched to a
  negation-list selector that covers every typed input we use, plus
  -webkit-appearance:none + a 1px focus ring. Light + dark mode both
  show the same border/padding/focus state across all four account
  fields.
- Settings → SSO card now renders display name, IdP logo URL (new),
  and metadata source on one 3-column row. The metadata <select>
  inherits the same chrome as the text inputs so it baseline-aligns
  with them. SsoConfig grew an idp_logo_url field, persisted to
  sso.json, length-capped and validated to http(s) only.

Quality:
- 138 tests passing (was 132 in v0.4.5). +1 IdP-logo-URL validation,
  +1 PXE menu polish regression guard, +4 /branding/pxe-logo
  integration tests covering missing-config / SVG-fallback / raster-
  serve / post-auth public-allowlist cases.
- cargo clippy --workspace --all-targets clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-26 00:02:20 -04:00
Miles WardandClaude Opus 4.7 4a354a8664 v0.4.5: VMware UEFI fix, static musl binary, Forms auth + SSO config
VMware UEFI / Casper boot fix:
- Linux cmdline for Debian/Ubuntu/Mint/Pop!_OS/elementary now uses the
  canonical Casper `iso-url=` option and `ds=nocloud`, matching the
  fix Bootimus shipped in v0.1.67. The previous
  `boot=casper netboot=url url=… ip=dhcp ---` form booted fine on
  bare-metal UEFI but hung at "cloud-init running" on VMware guests
  because subiquity / cloud-init can't reach a metadata datasource
  through PXE.

Static binary (matches Bootimus v0.1.70):
- Dockerfile build stage now compiles against
  x86_64-unknown-linux-musl. The resulting /openpxe has no glibc
  dependency at all; the runtime stage still ships Debian slim for the
  samba/wimtools/nfs-common shellouts, but a future scratch/distroless
  variant is now a one-line swap. Cuts a class of "GLIBC_2.39 not
  found" surprises on older RHEL/Rocky hosts.

Forms auth (Sonarr/Radarr-style):
- New AdminStore in openpxe-core: single admin record persisted to
  <work_dir>/auth.json, bcrypt-hashed credentials, rotation requires
  current password.
- New SessionStore in openpxe-http-api: in-memory UUID-keyed sessions
  with 24h sliding TTL, openpxe_session HttpOnly cookie.
- Endpoints: POST /api/setup (first-run), POST /api/login, POST
  /api/logout, GET /api/me, PUT /api/me/credentials (rotates and
  revokes every other session).
- Auth middleware gates /api/* once the admin is configured;
  passes through entirely until then (tests + fresh installs ride this
  path). Allowlists PXE-essential paths (/boot.ipxe, /iso/*, /ipxe/*,
  /api/queue/join, /api/queue/poll/*) so iPXE clients still work
  without a cookie they can't send.
- WebUI: first-run setup card, login card, logout chip in the sidebar
  footer, Account card in Settings for rotating creds. Auth screen is
  fully styled (centered narrow card, matches Sonarr layout).

SSO config (FleetDM-shaped, storage-only):
- New SsoStore in openpxe-core: { enabled, idp_name, metadata,
  metadata_url } persisted to <work_dir>/sso.json with size caps and
  URL-scheme validation.
- Endpoints: GET /api/sso, PUT /api/sso. Validation: enabling SSO
  without either metadata or metadata_url returns 400.
- WebUI: SSO card in Settings with a URL-vs-XML mode switch and an
  inert "Sign in with X" button on the login screen while runtime
  flow is pending. Per the brief: no Entity ID field (defaults to the
  advertised public_base_url internally when SAML wiring lands).

Quality:
- 132 tests passing (was 106 in v0.4.4): +5 auth unit tests, +5 SSO
  unit tests, +7 auth integration tests, +1 SSO integration test, +1
  regression guard pinning the new Casper cmdline.
- cargo clippy --workspace --all-targets clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-25 22:37:20 -04:00
Miles WardandClaude Opus 4.7 55d74662c2 v0.4.4: Settings tab, API reference, ISO category, branding, disk space
Settings:
- New top-level Settings tab. Carries a placeholder for the planned
  LDAP / OIDC / user-management work, the new branding controls, and
  the API reference at the bottom.
- Custom logo upload (PNG/SVG/JPEG/WebP/GIF up to 2 MB) replaces the
  bundled brand mark via /assets/logo.svg; bytes live at
  <work_dir>/branding/ and survive restart. The original "OpenPXE
  v<x.y.z>" pins to the sidebar footer for support.
- API reference rendered from a new GET /api/docs into a per-method
  coloured pill list grouped by area.

ISO category (Storage):
- New IsoCategory { Os, Tools } on IsoMeta with PUT
  /api/isos/:id/category. Storage table's Type cell becomes a
  dropdown; selecting Tools moves the ISO into the Tools submenu next
  to memtest / shell / NIC info and removes it from the OS Installers
  family submenu. Family detection still drives BIOS/UEFI / kernel
  args; only the menu placement changes.

Storage telemetry:
- New IsoStore::disk_usage (libc::statvfs, lives in iso-store so the
  http-api crate stays #![forbid(unsafe_code)]) and GET
  /api/storage/disk. The Storage tab now shows free/used/total for
  the volume hosting the ISO directory with an 80%/95% colour ramp.

UI polish:
- Brand block in the sidebar now matches the topbar height exactly,
  so the divider runs straight across the top of the app rather than
  stepping; version label moved out of the brand and pinned to the
  sidebar footer ("OpenPXE v0.4.4").
- Light-mode terminal: --terminal-bg + per-level text colours track
  the active theme rather than being hard-coded dark.
- About: lead paragraph spans the full content width; new Docs row
  links to https://openpxe.com/.

106 tests passing (was 89 in v0.4.1, +17 across branding unit tests
and new integration coverage for category / disk / docs / branding).
cargo clippy --workspace --all-targets clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-25 17:46:52 -04:00
Miles Ward aa178cee93 make container builds reproducible
Commit Cargo.lock, copy it into the Docker build stage, and align the Docker Rust base/MSRV with the toolchain required by the locked dependency graph.
2026-05-24 13:53:10 -04:00
Miles Ward 2b1ec3e463 fix docker build toolchain selection
Do not copy rust-toolchain.toml into the Docker build stage so the release image uses the Rust toolchain provided by the base image instead of downloading latest stable inside the container.
2026-05-24 13:49:09 -04:00
Miles Ward 55ace0c25c v0.4.1: harden ISO uploads and beta UI polish
Add browser-safe chunked ISO uploads with progress, partial-file visibility, offset validation, and abort cleanup while keeping the legacy multipart endpoint for API clients.

Record host-log validation coverage, keep the queue/status UI copy clean, move release docs to 0.4.1, and tighten the dark theme to a near-black Netbox-style palette.
2026-05-24 13:45:35 -04:00
Miles WardandClaude Opus 4.7 2a284afd02 v0.4.0: upload telemetry, host log, jet-black UI
- Upload reliability + diagnostics:
  - api_upload_iso now distinguishes clean EOF from mid-stream errors;
    a truncated multipart body (proxy buffer cap, network drop) returns
    400 with the cause and a "try the LAN IP" hint instead of silently
    finalising a partial file.
  - Per-stage tracing (begin/MB-watermark/finish/abort) so a stuck
    upload is debuggable from the Terminal tab.
  - Web upload UI surfaces bytes/total, percent, throughput, ETA, and
    maps 413/502/504/network-drop to actionable hints.
- New BootLog feature under Hosts:
  - openpxe-core::BootLog — bounded in-memory ring (500) + append-only
    JSONL on disk, recording (timestamp, mac, ip, target_id,
    target_title) every time a boot entry script is served.
  - iPXE per-entry chain URLs grow ?mac=${mac}; password prompt
    submission carries it through; host-binding short-circuit uses the
    bound MAC. ConnectInfo<SocketAddr> wired for peer IP capture (with
    optional fallback so tower::oneshot in tests still works).
  - GET /api/boot-log endpoint + Host log table under the Hosts tab.
- UI changes:
  - Queue card header "Forge" → "Status".
  - Removed Tinkerbell attribution sentence from Hosts tab.
  - Topbar readiness chip moved into the sidebar footer as
    "Service status: Ready / Advertised to clients / <url>", grouping
    advertised PXE URL with operator-relevant status.
  - Jet-black dark palette (#000 / #0a0a0a / #141414 / #1c1c1c)
    replacing the blue-tinted ramp; terminal toolbar/input recoloured
    to match.
- 89 tests passing (was 85 in v0.3.2); cargo clippy --workspace
  --all-targets clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-24 13:10:40 -04:00
Miles Ward bbdbb8df43 v0.3.2: OpenPXE naming cleanup and beta hardening
- remove remaining PXEForge/Gate/anvil wording from code, docs, UI, and deployment examples

- fix Queue tab view wiring and rename queue-facing terminal/API copy

- harden raw ISO Range handling, iPXE fallback lines, and WinPE SMB reconnect behavior

- bump workspace and deployment examples to 0.3.2
2026-05-21 02:13:08 -04:00
Miles Ward 9c6903351f v0.3.1: per-ISO boot password gate
Operators can now lock individual ISOs behind a password set in the
WebUI. Picking a locked image at the PXE menu prompts the operator on
the client console; the boot script is only released after a correct
match. The plaintext never leaves the request — server stores bcrypt
hashes, scripts never echo the candidate.

## Backend

- New optional `password_hash: Option<String>` on `IsoMeta`. Skipped
  during serialize when None, so existing meta.json files don't grow
  a noisy `null` field.
- `IsoStore::set_password(id, Some("pw"))` hashes via bcrypt
  `DEFAULT_COST` (10 — fast enough for an interactive iPXE prompt,
  expensive enough to be hostile to brute force on a leaked
  meta.json). `set_password(id, None)` and `set_password(id, Some(""))`
  both clear.
- `IsoStore::verify_password` returns Ok(true) when no password is
  set, so the gate stays open for the common case.
- `IsoMeta::is_password_protected()` predicate the HTTP layer + UI
  share.
- NFS-sourced ISOs persist their hash in memory only — the share is
  the source of truth for those, and it doesn't carry hash sidecars.

## HTTP API

- `PUT /api/isos/:id/password` body `{ "password": "..." }` to set,
  `{ "password": null }` (or empty string) to clear.
- `DELETE /api/isos/:id/password` for the explicit clear.
- Both 204 on success, 404 for unknown ids.
- `/boot/<entry>.ipxe` now intercepts:
  - no `?token=`        -> render password-prompt script
  - `?token=<wrong>`    -> render auth-fail script (sleeps 2s, chains
                           back to the entry which re-prompts)
  - `?token=<correct>`  -> render the real boot script
  - ISO without password ignores token entirely (per-MAC bookmarks
    still work without changes).

## iPXE prompt

`render_password_prompt`:
- `set password ` then `read --secret password` — accepts input
  without echoing.
- Empty input chains back to the main menu (lets the operator back
  out of a misclick).
- Submit chains `?token=${password:uristring}`. The `:uristring`
  modifier URL-encodes the value, so passwords with `&`, `?`, `=`,
  spaces, etc. survive transport.

`render_password_failed`:
- Single line saying so + 2s sleep, then re-chains the entry.
- Server-side WARN log records the entry id only, never the
  candidate value (verified in smoke test).

## UI

Storage tab's image table grows an `Auth` column showing
`protected` / `open`, plus a 🔒 next to the filename when locked.
Per-row "Set password" / "Password ✎" button toggles an inline
editor in the next table row containing:
- a "Password protect this image" checkbox
- a `<input type=password autocomplete=new-password>` (hidden when
  the checkbox is off)
- a Save button

Save calls PUT or DELETE on `/api/isos/:id/password` based on the
checkbox state and clears the input field before re-rendering, so
the plaintext doesn't sit in the DOM longer than needed.

## Menu indicator

`render_family_menu` adds a `*` prefix immediately before the size
box on protected entries — ASCII only because some firmware menu
consoles mangle non-ASCII glyphs. Looks like:

  item --key 1 win11_test-winpe *[ 5234 MB] Windows 11 Test ISO

## Tests

74 passing across the workspace (was 66 in v0.3.0):
- 3 new store unit tests (bcrypt round-trip, unknown-id error,
  meta.json persistence across restart)
- 2 new ipxe_script unit tests (prompt/auth-fail invariants:
  read --secret, uristring, no candidate echo)
- 3 new HTTP integration tests (full gate flow upload-set-prompt-
  fail-success-clear, null/empty bodies, 404 on unknown id)

cargo clippy --workspace --all-targets clean.

Local smoke verified upload + lock + prompt + auth-fail + correct +
menu indicator + log scrub on a real release binary.

## Operational notes

- HTTP, not HTTPS — token rides in the query string. Acceptable on
  a trusted boot VLAN; do NOT expose OpenPXE to untrusted networks
  with this feature relied on for security. Reverse-proxy in front
  of OpenPXE will end up with the token in access logs.
- bcrypt cost is `DEFAULT_COST` (10). One verify takes ~50ms on
  modern x86, which is the worst-case latency added to a correct
  boot. Tunable via the bcrypt crate if needed.
2026-05-06 22:35:11 -04:00
Miles Ward 90a23a8c96 docs(runbook): apply OpenPXE rebrand to network-boot runbook
The Linux network-boot runbook landed on origin/main while the v0.3.0
rebrand was in flight on local main. Runs the same string-rewrite
pass: PXEForge → OpenPXE, Gated → Queued, /api/gate → /api/queue,
PXEFORGE_ env vars → OPENPXE_, container path under
/var/lib/openpxe.
2026-05-06 14:14:09 -04:00
Miles Ward e3452fe976 v0.3.0 — rebrand: PXEForge → OpenPXE, Gated → Queued Deployment
Full rename to match the openpxe.com brand. The product now reads as a
polished open-source project rather than a personal-tool nickname:
the anvil/forge metaphor is gone, replaced with the rainbow-horizon
brand mark from the marketing site.

## Naming changes

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

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

## Brand assets

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

## CSS rename

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

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

## Validation

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

## Migration notes for operators on v0.2.0

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

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

## New features

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

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

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

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

## Code cleanup pass

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

## Windows workflow validation

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

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

## Docs

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

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

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

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

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

## Phase 1 — protocol stack

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

## Phase 2 — UX + Windows

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

## Phase 3 — MVP hardening

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

## Phase 4 — UI restructure + remote storage

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

## Tests

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

cargo build --workspace and cargo clippy --workspace --all-targets
both finish clean (warnings only, no errors).
2026-04-29 02:47:00 -04:00
49 changed files with 1497 additions and 5873 deletions
+16
View File
@@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"Bash(cargo check *)",
"Bash(cargo build *)",
"Bash(cargo clippy *)",
"Bash(cargo fmt *)",
"Bash(cargo tree *)",
"Bash(cargo doc *)",
"Bash(cargo test --workspace --lib)",
"Bash(cargo test --workspace)",
"Bash(cargo --version)",
"Bash(rustc --version)"
]
}
}
-3
View File
@@ -11,6 +11,3 @@ data/work/
.claude/settings.local.json
.claude/worktrees/
.claude/scheduled_tasks.lock
# local editor / agent settings (not part of the project)
.claude/
Generated
+757 -1141
View File
File diff suppressed because it is too large Load Diff
+23 -22
View File
@@ -12,7 +12,7 @@ members = [
]
[workspace.package]
version = "0.7.4"
version = "0.5.8"
edition = "2021"
rust-version = "1.95"
license = "MIT OR Apache-2.0"
@@ -20,23 +20,21 @@ repository = "https://gitea.milesward.dev/mward4/OpenPXE"
authors = ["OpenPXE contributors"]
[workspace.dependencies]
tokio = { version = "1.52", features = ["full"] }
tokio = { version = "1.40", features = ["full"] }
tokio-util = { version = "0.7", features = ["io"] }
tokio-stream = { version = "0.1", features = ["sync"] }
futures = "0.3"
async-trait = "0.1"
# v0.6.2: dhcproto 0.15 drops the deprecated trust-dns-proto dependency
# (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"] }
dhcproto = "0.12"
socket2 = { version = "0.5", features = ["all"] }
bytes = "1.7"
nom = "7.1"
axum = { version = "0.8", features = ["macros", "multipart", "http2"] }
axum = { version = "0.7", features = ["macros", "multipart", "http2"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] }
hyper = "1.9"
hyper = "1.4"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
serde = { version = "1.0", features = ["derive"] }
@@ -54,11 +52,9 @@ thiserror = "2.0"
clap = { version = "4.5", features = ["derive", "env"] }
uuid = { version = "1.10", features = ["v4", "serde"] }
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"
hex = "0.4"
bcrypt = "0.19"
bcrypt = "0.15"
parking_lot = "0.12"
rust-embed = { version = "8.5", features = ["include-exclude"] }
@@ -80,7 +76,7 @@ lettre = { version = "0.11", default-features = false, features = ["smtp-transpo
# C deps), so the static musl binary stays OpenSSL-free — samael was
# rejected precisely because it hard-requires OpenSSL. We build the thin
# SP layer (AuthnRequest, metadata parse, SAMLResponse semantics) on top.
bergshamra = "0.5"
bergshamra = "0.4"
roxmltree = "0.21"
quick-xml = "0.40"
x509-parser = "0.18"
@@ -99,19 +95,24 @@ base64 = "0.22"
# 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).
# CRITICAL #2 — pinned to EXACTLY 0.55.0, the newest russh that
# coexists with bergshamra-crypto (our SAML core). The RustCrypto
# ecosystem is mid-transition: bergshamra-crypto pins a constellation of
# release-CANDIDATE crates (`pkcs8 =0.11.0-rc.11` and its matching
# pkcs5/spki RCs) that are API-incompatible with the STABLE versions of
# the same crates in the same semver bucket. russh 0.56+ pulls those
# stable crates (`pkcs5 0.8`), which silently replaces bergshamra's RC
# copies and breaks compilation. russh ≤0.55 stays on the previous stable
# generation (`pkcs5 0.7`, `ssh-key 0.6`), which unifies with bergshamra's
# *stable* deps and leaves the RC bucket untouched — verified to compile.
# 0.55 still has the merged `russh::keys` API (keys merged at 0.50).
# IMPORTANT: do NOT bump russh past 0.55 until bergshamra-crypto adopts
# the stable RustCrypto generation; 0.56+ will not compile in this tree.
#
# 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 = { version = "=0.55.0", default-features = false, features = ["ring"] }
russh-sftp = "2.3"
openpxe-core = { path = "crates/core" }
+8 -8
View File
@@ -14,7 +14,7 @@
</p>
<p align="center">
<img alt="release" src="https://img.shields.io/badge/release-v0.6.0-2874d7" />
<img alt="release" src="https://img.shields.io/badge/release-v0.5.5-2874d7" />
<img alt="license" src="https://img.shields.io/badge/license-MIT%20%7C%20Apache--2.0-59824f" />
<img alt="rust" src="https://img.shields.io/badge/built%20with-Rust-fb8841?logo=rust&logoColor=white" />
<img alt="container" src="https://img.shields.io/badge/container--native-OCI%20%C2%B7%20OpenShift-2496ED?logo=docker&logoColor=white" />
@@ -23,7 +23,7 @@
---
OpenPXE turns bare-metal provisioning into a single container with a web UI. It's a
OpenPXE turns bare-metal provisioning into a single container with a web UI. It's a work in progress
ground-up Rust reimplementation of [iVentoy (ventoy/PXE)](https://github.com/ventoy/PXE),
designed for Docker/OCI and OpenShift instead of a Windows desktop — so it drops onto
an Unraid box, a Linux server, or a Kubernetes cluster and just runs.
@@ -32,7 +32,7 @@ Upload `.iso` files (or point at a remote share), and any machine on the network
them — Linux installers, live tools, or stock Windows setup — with **zero iPXE knowledge
required by the operator.**
> **Status — v0.6.0, late pre-beta.** The full PXE stack, web UI, remote ISO libraries
> **Status — v0.5.8, late pre-beta.** The full PXE stack, web UI, remote ISO libraries
> (SMB/NFS/SFTP), Windows deployment, queued fleet rollout, SAML SSO, and Prometheus
> metrics are implemented and test-covered. The release checklist gates every tag on the
> full test suite + `clippy`. Currently in real-hardware validation.
@@ -120,14 +120,14 @@ docker build -f deploy/docker/Dockerfile -t openpxe:0.5.5 .
# proxy mode so the container sees DHCPDISCOVER broadcasts; set PUBLIC_IP to this
# host's LAN address so advertised boot URLs are reachable.
docker run -d --name openpxe --network host \
-e OPENPXE_PUBLIC_IP=10.0.0.5 \
-e OPENPXE_PUBLIC_IP=10.0.0.5:4200 \
-e OPENPXE_DHCP_MODE=proxy \
-v $PWD/data/isos:/var/lib/openpxe/isos \
-v $PWD/data/work:/var/lib/openpxe/work \
openpxe:0.5.5
openpxe:0.5.8
# Open the UI and drop an ISO in.
open http://10.0.0.5
open http://10.0.0.5:4200
```
> On macOS/Windows, Docker runs inside a Linux VM, so "host network" means the VM — use
@@ -147,8 +147,8 @@ cargo run --release # needs root or CAP_NET_BIND_SERVICE for :80/:6
docker run --rm \
-v /my/iso-library:/seed:ro \
-v openpxe-data:/var/lib/openpxe/isos \
-e OPENPXE_PUBLIC_IP=10.0.0.5 \
openpxe:0.5.5 seed --from /seed # add --dry-run to preview
-e OPENPXE_PUBLIC_IP=10.0.0.5:4200 \
openpxe:0.5.8 seed --from /seed # add --dry-run to preview
```
## Remote ISO libraries
+10 -189
View File
@@ -23,36 +23,6 @@ pub enum ClientArch {
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 {
#[must_use]
pub fn from_option_93(value: u16) -> Self {
@@ -69,73 +39,21 @@ impl ClientArch {
/// Default iPXE binary filename to return via TFTP for this architecture.
/// Uses `snponly` variants which reuse the firmware's UNDI/SNP network
/// stack — smaller binaries and broader hardware compatibility than the
/// all-drivers-included `ipxe.efi`. Equivalent to
/// [`Self::ipxe_bootfile_mode`] with [`DriverMode::Firmware`]; kept as a
/// convenience for the common firmware-net path.
/// all-drivers-included `ipxe.efi`.
#[must_use]
pub fn ipxe_bootfile(self) -> Option<&'static str> {
self.ipxe_bootfile_mode(DriverMode::Firmware)
}
/// iPXE binary filename for this architecture under a given network
/// [`DriverMode`].
///
/// * [`DriverMode::Firmware`] — the `snponly`/`undionly` builds that reuse
/// the firmware's UNDI/SNP NIC stack. Smallest, and the most reliable
/// 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,
Some(match self {
Self::LegacyX86 => "undionly.kpxe",
Self::Ia32Uefi => "snponly-i386.efi",
Self::X64Uefi => "snponly.efi",
// ARM32 UEFI: upstream boot.ipxe.org does not publish a prebuilt
// snponly variant for this arch. We return None so the DHCP
// proxy declines rather than advertising a file we can't serve.
Self::Arm32Uefi | Self::Unknown(_) => return None,
Self::Arm64Uefi => "snponly-arm64.efi",
})
}
/// 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]
pub fn as_str(self) -> &'static str {
match self {
@@ -215,103 +133,6 @@ mod tests {
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]
fn firmware_class_detects_ipxe_over_pxeclient() {
let c = FirmwareClass::classify(Some(b"PXEClient:Arch:00007"), Some(b"iPXE"));
+4 -2
View File
@@ -125,7 +125,9 @@ impl AdminStore {
{
let mut g = self.inner.write();
if g.admin.is_some() {
return Err(Error::Invalid("admin account already configured".into()));
return Err(Error::Invalid(
"admin account already configured".into(),
));
}
g.admin = Some(admin.clone());
}
@@ -344,7 +346,7 @@ mod tests {
assert!(s.bootstrap("", "hunter2hunter2").is_err());
assert!(s.bootstrap("ad:min", "hunter2hunter2").is_err()); // ':' reserved
assert!(s.bootstrap("admin", "short").is_err()); // <8 chars
// 65-char username is too long.
// 65-char username is too long.
let long = "a".repeat(65);
assert!(s.bootstrap(&long, "hunter2hunter2").is_err());
}
-369
View File
@@ -1,369 +0,0 @@
//! Label-based boot rules + boot-decision webhook (v0.7.0).
//!
//! Generalizes [`crate::host_bindings::HostBindings`] (exact-MAC pins)
//! into ordered, first-match-wins rules over what the boot chain knows
//! about a client — MAC prefix (OUI or longer) and firmware
//! architecture — plus an optional outbound webhook so external
//! automation (CMDB, netbox, a shell script) can decide the boot target
//! per machine, pixiecore-style.
//!
//! Decision order in the boot script handler, most-specific first:
//! 1. exact per-MAC host binding (operator pin)
//! 2. first matching enabled rule here
//! 3. webhook, if configured (fail-open: timeout/error → menu)
//! 4. interactive menu
//!
//! With no rules and no webhook configured the behavior is byte-for-byte
//! what it was before this feature existed — no toggles to flip.
//!
//! Persisted to `<work_dir>/boot_rules.json` with the same "in-memory
//! authoritative, disk is a crash cache, corruption falls back to empty"
//! policy as the host bindings — a bad rules file must never block PXE.
use crate::host_bindings::normalize_mac;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
/// One ordered rule. All present (non-empty) selectors must match —
/// empty selector fields match anything, so a rule with only `arch` set
/// applies to every client of that architecture.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootRule {
/// Case-insensitive MAC prefix, `:`-separated (e.g. `dc:a6:32` for
/// an OUI, or longer). Empty = any MAC.
#[serde(default)]
pub mac_prefix: String,
/// Client architecture selector — matches `ClientArch::as_str()`
/// (`bios`, `uefi-x64`, `uefi-ia32`, `uefi-arm64`). Empty = any.
#[serde(default)]
pub arch: String,
/// Boot entry id (a `BootEntry::id`) or reserved menu name
/// (`_local`, `_queue`, …) to chain to when this rule matches.
/// May be empty for a rule that only pins a driver mode.
#[serde(default)]
pub target: String,
/// v0.7.1: optional first-boot binary pin — `""` (auto: let the
/// escalation ladder decide), `"firmware"`, `"builtin"`, or
/// `"shim"`. Lets an operator declare "this rack is all Secure
/// Boot → serve the signed chain immediately", skipping the
/// learn-by-failing walk entirely for known fleets.
#[serde(default)]
pub driver_mode: String,
/// Rules can be parked without deleting them.
#[serde(default = "default_true")]
pub enabled: bool,
/// Operator note shown in the UI (`"all Pi 4s"`, `"QA rack"`).
#[serde(default)]
pub note: String,
}
fn default_true() -> bool {
true
}
/// The whole persisted config: ordered rules + optional webhook.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct BootRulesConfig {
pub rules: Vec<BootRule>,
/// Optional boot-decision webhook URL. When set, unmatched boots GET
/// `<url>?mac=<mac>&arch=<arch>` and a `200 {"target": "<id>"}`
/// reply chains to that target. Anything else (404, timeout, bad
/// JSON) falls through to the menu. Empty = disabled.
pub webhook_url: String,
}
/// Store for the rules config. Cheap to clone; locks held briefly.
#[derive(Debug, Clone)]
pub struct BootRulesStore {
path: Arc<PathBuf>,
inner: Arc<RwLock<BootRulesConfig>>,
}
impl BootRulesStore {
/// Load from `work_dir/boot_rules.json`, or start empty if absent /
/// unreadable.
#[must_use]
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("boot_rules.json");
let inner = match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str::<BootRulesConfig>(&text) {
Ok(cfg) => cfg,
Err(e) => {
tracing::warn!(
target: "openpxe::boot_rules",
"boot_rules.json present but unreadable ({e}); starting empty"
);
BootRulesConfig::default()
}
},
Err(_) => BootRulesConfig::default(),
};
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(inner)),
}
}
/// Current config snapshot (for the API / UI).
#[must_use]
pub fn snapshot(&self) -> BootRulesConfig {
self.inner.read().clone()
}
/// Replace the whole config (the UI saves the full table at once —
/// rules are ordered, so partial updates would be ambiguous).
pub fn replace(&self, mut cfg: BootRulesConfig) {
for r in &mut cfg.rules {
r.mac_prefix = normalize_mac(&r.mac_prefix);
r.arch = r.arch.trim().to_ascii_lowercase();
r.target = r.target.trim().to_string();
r.driver_mode = r.driver_mode.trim().to_ascii_lowercase();
r.note = r.note.trim().to_string();
}
cfg.webhook_url = cfg.webhook_url.trim().to_string();
*self.inner.write() = cfg;
self.persist();
}
/// Webhook URL, when configured.
#[must_use]
pub fn webhook_url(&self) -> Option<String> {
let g = self.inner.read();
if g.webhook_url.is_empty() {
None
} else {
Some(g.webhook_url.clone())
}
}
/// First enabled rule matching `(mac, arch)`, in stored order.
/// `arch` is the `ClientArch::as_str()` form when the boot chain
/// passed one along, `None` otherwise (older chains).
#[must_use]
pub fn match_target(&self, mac: &str, arch: Option<&str>) -> Option<String> {
self.first_match(mac, arch, |r| {
(!r.target.is_empty()).then(|| r.target.clone())
})
}
/// v0.7.1: first enabled rule that pins a driver mode for `(mac,
/// arch)`. Consulted by the DHCP proxy *before* the automatic
/// escalation ladder — an operator who knows a rack is all Secure
/// Boot pins it to `shim` and those machines never walk the ladder.
/// Unknown mode strings are ignored (forward compatibility).
#[must_use]
pub fn driver_mode_hint(&self, mac: &str, arch: Option<&str>) -> Option<crate::DriverMode> {
self.first_match(mac, arch, |r| match r.driver_mode.as_str() {
"firmware" => Some(crate::DriverMode::Firmware),
"builtin" => Some(crate::DriverMode::Builtin),
"shim" => Some(crate::DriverMode::Shim),
_ => None,
})
}
/// Shared rule-matching walk: returns the first `extract` result from
/// an enabled rule whose selectors match. Rules that match but yield
/// `None` from `extract` (e.g. no target set, or no driver mode set)
/// don't stop the walk — target rules and mode-pin rules coexist.
fn first_match<T>(
&self,
mac: &str,
arch: Option<&str>,
extract: impl Fn(&BootRule) -> Option<T>,
) -> Option<T> {
let mac = normalize_mac(mac);
let g = self.inner.read();
for r in &g.rules {
if !r.enabled {
continue;
}
if !r.mac_prefix.is_empty() && !mac.starts_with(r.mac_prefix.as_str()) {
continue;
}
if !r.arch.is_empty() {
// An arch-selective rule can only match when the chain
// told us the client's arch.
match arch {
Some(a) if a.eq_ignore_ascii_case(&r.arch) => {}
_ => continue,
}
}
if let Some(v) = extract(r) {
return Some(v);
}
}
None
}
fn persist(&self) {
let snap = self.inner.read().clone();
let body = match serde_json::to_vec_pretty(&snap) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::boot_rules", "serialize boot_rules.json: {e}");
return;
}
};
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = self.path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::boot_rules", "write boot_rules.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::warn!(target: "openpxe::boot_rules", "rename boot_rules.json: {e}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn rule(mac_prefix: &str, arch: &str, target: &str) -> BootRule {
BootRule {
mac_prefix: mac_prefix.into(),
arch: arch.into(),
target: target.into(),
driver_mode: String::new(),
enabled: true,
note: String::new(),
}
}
#[test]
fn driver_mode_hint_pins_known_modes_and_ignores_unknown() {
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
let mut sb_rack = rule("aa:bb:cc", "", "");
sb_rack.driver_mode = "SHIM".into(); // normalized on replace
let mut weird = rule("11:22:33", "", "");
weird.driver_mode = "quantum".into(); // unknown → ignored
s.replace(BootRulesConfig {
rules: vec![sb_rack, weird],
webhook_url: String::new(),
});
assert_eq!(
s.driver_mode_hint("aa:bb:cc:00:00:01", None),
Some(crate::DriverMode::Shim)
);
assert_eq!(s.driver_mode_hint("11:22:33:00:00:01", None), None);
assert_eq!(s.driver_mode_hint("99:99:99:00:00:01", None), None);
}
#[test]
fn mode_pin_rule_does_not_shadow_later_target_rule() {
// A mode-only rule and a target rule can both apply to the same
// client: the mode pin must not consume the target walk.
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
let mut pin = rule("aa:bb", "", "");
pin.driver_mode = "builtin".into();
s.replace(BootRulesConfig {
rules: vec![pin, rule("aa:bb", "", "rack-image")],
webhook_url: String::new(),
});
assert_eq!(
s.driver_mode_hint("aa:bb:00:00:00:01", None),
Some(crate::DriverMode::Builtin)
);
assert_eq!(
s.match_target("aa:bb:00:00:00:01", None).as_deref(),
Some("rack-image")
);
}
#[test]
fn empty_config_matches_nothing() {
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
assert!(s
.match_target("aa:bb:cc:dd:ee:ff", Some("uefi-x64"))
.is_none());
assert!(s.webhook_url().is_none());
}
#[test]
fn first_match_wins_in_order() {
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
s.replace(BootRulesConfig {
rules: vec![
rule("aa:bb:cc", "", "rack-image"),
rule("", "", "catch-all"),
],
webhook_url: String::new(),
});
assert_eq!(
s.match_target("AA-BB-CC-00-00-01", None).as_deref(),
Some("rack-image")
);
assert_eq!(
s.match_target("11:22:33:44:55:66", None).as_deref(),
Some("catch-all")
);
}
#[test]
fn arch_selector_requires_known_arch() {
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
s.replace(BootRulesConfig {
rules: vec![rule("", "uefi-arm64", "arm-image")],
webhook_url: String::new(),
});
assert_eq!(
s.match_target("aa:bb:cc:00:00:01", Some("uefi-arm64"))
.as_deref(),
Some("arm-image")
);
// Wrong arch, or arch unknown to the chain → no match.
assert!(s.match_target("aa:bb:cc:00:00:01", Some("bios")).is_none());
assert!(s.match_target("aa:bb:cc:00:00:01", None).is_none());
}
#[test]
fn disabled_rules_are_skipped() {
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
let mut r = rule("", "", "x");
r.enabled = false;
s.replace(BootRulesConfig {
rules: vec![r],
webhook_url: String::new(),
});
assert!(s.match_target("aa:bb:cc:00:00:01", None).is_none());
}
#[test]
fn config_round_trips_to_disk() {
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
s.replace(BootRulesConfig {
rules: vec![rule("DC-A6-32", "", "pi-image")],
webhook_url: " http://automation/boot ".into(),
});
drop(s);
let s2 = BootRulesStore::load_or_default(dir.path());
// Prefix was normalized on replace, webhook trimmed.
assert_eq!(
s2.match_target("dc:a6:32:01:02:03", None).as_deref(),
Some("pi-image")
);
assert_eq!(s2.webhook_url().as_deref(), Some("http://automation/boot"));
}
#[test]
fn corrupt_file_falls_back_to_empty() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("boot_rules.json"), b"{nope").unwrap();
let s = BootRulesStore::load_or_default(dir.path());
assert!(s.snapshot().rules.is_empty());
}
}
-138
View File
@@ -1,138 +0,0 @@
//! One-time(ish) access tokens for unattended answer files (v0.7.0).
//!
//! Why: answer files routinely embed credentials (local admin passwords,
//! domain-join accounts, root hashes). Serving them to anyone who can
//! GET `/unattended/<id>` is exactly the exposure that got WDS
//! hands-free deployment disabled upstream (CVE-2026-0386 hardening
//! guidance). OpenPXE generates every answer-file URL it injects into a
//! boot chain, so it can scope each URL to the boot that requested it:
//! when a boot script is rendered, a short-lived token is minted and
//! appended; the serving endpoint requires it (or a logged-in operator
//! session, so browser testing keeps working).
//!
//! Deliberately multi-use within the TTL rather than strictly one-shot:
//! real installers fetch the same file more than once (initramfs +
//! installer stage, cloud-init retries), and the token's job is to stop
//! *unrelated* hosts from harvesting credentials, not to count fetches.
//!
//! In-memory only. A server restart invalidates outstanding tokens —
//! acceptable because a restart also interrupts the ISO streaming an
//! in-flight install depends on, and the next boot mints fresh ones.
use parking_lot::Mutex;
use std::collections::HashMap;
use std::time::{Duration, Instant};
use uuid::Uuid;
/// Long enough to cover a slow OS install end-to-end (the answer file is
/// fetched early, but cloud-init can re-read late), short enough that a
/// leaked URL goes stale the same afternoon.
const TOKEN_TTL: Duration = Duration::from_hours(4);
/// Hard cap on outstanding tokens; past it the oldest is evicted. Tokens
/// are minted once per boot-script render, so this only matters under
/// abuse, and serving must never become a memory-growth vector.
const MAX_TOKENS: usize = 4096;
#[derive(Debug, Clone)]
struct Grant {
file_id: String,
issued: Instant,
}
/// In-memory token table. Cheap to clone (`Arc`-shared).
#[derive(Debug, Clone, Default)]
pub struct BootTokens {
inner: std::sync::Arc<Mutex<HashMap<String, Grant>>>,
}
impl BootTokens {
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Mint a token granting access to unattended file `file_id` for the
/// next [`TOKEN_TTL`]. Returns the opaque token value to embed in the
/// generated URL.
#[must_use]
pub fn mint(&self, file_id: &str) -> String {
self.mint_at(file_id, Instant::now())
}
/// Is `token` a live grant for `file_id`?
#[must_use]
pub fn check(&self, token: &str, file_id: &str) -> bool {
self.check_at(token, file_id, Instant::now())
}
fn mint_at(&self, file_id: &str, now: Instant) -> String {
let token = Uuid::new_v4().simple().to_string();
let mut g = self.inner.lock();
g.retain(|_, gr| now.duration_since(gr.issued) < TOKEN_TTL);
if g.len() >= MAX_TOKENS {
if let Some(oldest) = g
.iter()
.min_by_key(|(_, gr)| gr.issued)
.map(|(k, _)| k.clone())
{
g.remove(&oldest);
}
}
g.insert(
token.clone(),
Grant {
file_id: file_id.to_string(),
issued: now,
},
);
token
}
fn check_at(&self, token: &str, file_id: &str, now: Instant) -> bool {
let g = self.inner.lock();
g.get(token)
.is_some_and(|gr| gr.file_id == file_id && now.duration_since(gr.issued) < TOKEN_TTL)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mint_then_check_round_trip() {
let t = BootTokens::new();
let tok = t.mint("ks-1");
assert!(t.check(&tok, "ks-1"));
// Multi-use within TTL: a second fetch still passes.
assert!(t.check(&tok, "ks-1"));
// Wrong file id never passes, even with a live token.
assert!(!t.check(&tok, "ks-2"));
// Unknown token never passes.
assert!(!t.check("nope", "ks-1"));
}
#[test]
fn token_expires_after_ttl() {
let t = BootTokens::new();
let now = Instant::now();
let tok = t.mint_at("ks-1", now);
let just_before = TOKEN_TTL.checked_sub(Duration::from_secs(1)).unwrap();
assert!(t.check_at(&tok, "ks-1", now + just_before));
assert!(!t.check_at(&tok, "ks-1", now + TOKEN_TTL + Duration::from_secs(1)));
}
#[test]
fn table_is_capped() {
let t = BootTokens::new();
let now = Instant::now();
let first = t.mint_at("f", now);
for i in 0..MAX_TOKENS {
let _ = t.mint_at(&format!("f{i}"), now + Duration::from_secs(1));
}
// The oldest grant was evicted to stay within the cap.
assert!(!t.check_at(&first, "f", now + Duration::from_secs(2)));
assert!(t.inner.lock().len() <= MAX_TOKENS);
}
}
+17
View File
@@ -12,9 +12,11 @@ use time::OffsetDateTime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ClientEvent {
DhcpDiscover,
DhcpRequest,
PxeBootServerRequest,
TftpRead { file: String },
HttpScriptFetch { target: String },
HttpIsoAsset { file: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -30,6 +32,8 @@ pub struct ClientSnapshot {
// Events are left with default serialization (9-tuple) — they're
// diagnostic only and not consumed by the UI today.
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)]
@@ -62,6 +66,7 @@ impl ClientRegistry {
first_seen: now,
last_seen: now,
events: Vec::new(),
selected_target: None,
});
entry.last_seen = now;
if ip.is_some() {
@@ -79,6 +84,13 @@ impl ClientRegistry {
}
}
pub fn set_selected_target(&self, mac: &str, target: Option<String>) {
let mut guard = self.inner.write();
if let Some(c) = guard.get_mut(mac) {
c.selected_target = target;
}
}
#[must_use]
pub fn list(&self) -> Vec<ClientSnapshot> {
let guard = self.inner.read();
@@ -87,4 +99,9 @@ impl ClientRegistry {
v.sort_by_key(|c| std::cmp::Reverse(c.last_seen));
v
}
#[must_use]
pub fn get(&self, mac: &str) -> Option<ClientSnapshot> {
self.inner.read().get(mac).cloned()
}
}
+6
View File
@@ -40,6 +40,10 @@ pub struct NetworkConfig {
pub dhcp_port: u16,
/// UDP port for PXE Boot Server discovery. Standard is 4011.
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)]
@@ -101,6 +105,8 @@ impl Default for NetworkConfig {
dhcp_bind: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
dhcp_port: 67,
pxe_port: 4011,
mac_allowlist: Vec::new(),
subnet_allowlist: Vec::new(),
}
}
}
+2 -6
View File
@@ -5,8 +5,6 @@
pub mod arch;
pub mod auth;
pub mod boot_log;
pub mod boot_rules;
pub mod boot_tokens;
pub mod branding;
pub mod client;
pub mod config;
@@ -23,11 +21,9 @@ pub mod settings;
pub mod sso;
pub mod wol;
pub use arch::{ClientArch, DriverMode, FirmwareClass};
pub use arch::{ClientArch, FirmwareClass};
pub use auth::{AdminAccount, AdminPublic, AdminStore};
pub use boot_log::{BootEvent, BootLog};
pub use boot_rules::{BootRule, BootRulesConfig, BootRulesStore};
pub use boot_tokens::BootTokens;
pub use branding::{ext_for_mime, BrandingStore, LogoSlot, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES};
pub use client::{ClientEvent, ClientRegistry, ClientSnapshot};
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
@@ -40,4 +36,4 @@ pub use profile::DeployProfile;
pub use queue::{DeploymentQueue, QueueEntry};
pub use saml::{IdpMetadata, SamlError, SpParams, VerifiedPrincipal, VerifiedResponse};
pub use settings::{Settings, SettingsStore, TimeoutAction};
pub use sso::{SsoConfig, SsoLoginInfo, SsoStore};
pub use sso::{SsoConfig, SsoStore};
+1 -4
View File
@@ -303,10 +303,7 @@ mod tests {
smtp_host: "smtp.example.com".into(),
..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 {
enabled: true,
kind: NotifyKind::Smtp,
-30
View File
@@ -73,23 +73,6 @@ 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.
#[derive(Debug, Clone)]
pub struct SsoStore {
@@ -128,19 +111,6 @@ impl SsoStore {
self.inner.read().clone()
}
/// Public, non-sensitive descriptor for the login screen. Safe to
/// expose pre-auth — it's exactly what the "Sign in with …" button
/// keys off, with no metadata/entity-ID leakage. v0.5.9.
#[must_use]
pub fn login_info(&self) -> SsoLoginInfo {
let cfg = self.inner.read();
SsoLoginInfo {
enabled: cfg.is_usable(),
idp_name: cfg.idp_name.clone(),
idp_logo_url: cfg.idp_logo_url.clone(),
}
}
/// Replace the whole config in one shot. Light validation: metadata
/// XML and URL are length-capped so an operator can't OOM us by
/// pasting a 10 GiB blob; the IdP UI tab clamps the input visually,
+5 -3
View File
@@ -181,7 +181,10 @@ mod tests {
Ipv4Addr::new(192, 168, 1, 255)
);
assert_eq!(
subnet_broadcast(Ipv4Addr::new(10, 5, 3, 7), Ipv4Addr::new(255, 255, 0, 0)),
subnet_broadcast(
Ipv4Addr::new(10, 5, 3, 7),
Ipv4Addr::new(255, 255, 0, 0)
),
Ipv4Addr::new(10, 5, 255, 255)
);
}
@@ -193,8 +196,7 @@ mod tests {
// and confirm send_magic transmits the exact 102-byte packet.
let rx = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).unwrap();
let port = rx.local_addr().unwrap().port();
rx.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.unwrap();
rx.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap();
let packet = magic_packet([0x0a, 0x1b, 0x2c, 0x3d, 0x4e, 0x5f]);
let sent = send_magic(&packet, &[Ipv4Addr::LOCALHOST], port).unwrap();
-6
View File
@@ -18,9 +18,3 @@ tracing.workspace = true
thiserror.workspace = true
anyhow.workspace = true
bytes.workspace = true
parking_lot.workspace = true
# v0.7.1: learned driver modes persist to <work_dir>/driver_modes.json.
serde_json.workspace = true
[dev-dependencies]
tempfile = "3.12"
-494
View File
@@ -1,494 +0,0 @@
//! Automatic per-MAC boot-binary escalation (v0.6.1, extended v0.7.x).
//!
//! OpenPXE serves the firmware-net iPXE build (`snponly`/`undionly`) by
//! default — it's the most reliable choice for chainloading because the
//! firmware just proved its network works by downloading the NBP. Two
//! classes of machine can't run it:
//!
//! * a minority of NICs have a missing or buggy firmware UNDI/SNP stack —
//! they TFTP the binary fine but iPXE can't bring the link up;
//! * Secure-Boot firmware downloads it fine but refuses to *execute* an
//! unsigned image.
//!
//! Both look identical from here: the tell-tale second DHCP DISCOVER
//! carrying the `iPXE` user-class never arrives and the machine
//! re-PXE-boots. So a fresh firmware DISCOVER from a MAC whose previous
//! attempt was never confirmed climbs one rung:
//! `Firmware → Builtin → Shim` (the signed shim+GRUB chain). The decision
//! is sticky; there is no operator toggle; the default path is unchanged
//! so hardware that already boots never regresses.
//!
//! v0.7.1 — **learned modes persist**. Walking the ladder costs one or
//! two failed boot cycles, so a machine should pay it once *ever*, not
//! once per idle window or server restart. Two events pin a MAC's mode
//! to disk (`<work_dir>/driver_modes.json`):
//!
//! * a confirmed iPXE handoff at a non-default mode (Builtin proved to
//! work — also Shim, via the GRUB→iPXE same-boot chainload);
//! * reaching the terminal Shim rung (Secure-Boot machines never produce
//! an iPXE handoff from the signed menu, so escalation itself is the
//! best knowledge we'll ever have).
//!
//! Pinned entries are immune to the TTL and reload at startup. The
//! operator escape hatch is a rules-level driver-mode pin (which
//! overrides this table entirely) or deleting `driver_modes.json`.
use openpxe_core::DriverMode;
use parking_lot::Mutex;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
/// Multiple DISCOVERs within this window belong to the *same* boot (DHCP
/// retransmits, plus the :4011 PXE Boot Server query that follows the :67
/// DISCOVER). They must not be mistaken for a failed-and-retried boot.
const SAME_BOOT_DEBOUNCE: Duration = Duration::from_secs(8);
/// Forget an *unpinned* MAC's state after this long with no activity, so
/// a transient mid-walk state doesn't linger and the map stays bounded.
/// Pinned (learned) entries are exempt — that's their whole point.
const ENTRY_TTL: Duration = Duration::from_mins(30);
/// Hard cap on tracked MACs. Past this we evict the least-recently-seen
/// entry (unpinned first) — escalation is best-effort, never a
/// memory-growth vector.
const MAX_ENTRIES: usize = 4096;
/// How often (at most) the whole map is swept for expired entries.
/// Correctness doesn't depend on the sweep — a stale entry is also
/// detected inline when its MAC next appears — so the sweep only bounds
/// memory for MACs that never return, and amortizing it keeps the
/// per-packet path O(1) instead of O(map).
const PRUNE_INTERVAL: Duration = Duration::from_mins(1);
#[derive(Debug, Clone, Copy)]
struct Entry {
mode: DriverMode,
/// True once we've served `mode` and are waiting for the iPXE handoff to
/// confirm it worked. A *new* boot arriving while this is still true means
/// the previous attempt failed and we should escalate.
awaiting_confirm: bool,
/// Learned mode (v0.7.1): persisted to disk, exempt from the TTL.
pinned: bool,
last_seen: Instant,
}
#[derive(Debug)]
struct Inner {
map: HashMap<String, Entry>,
/// When the last full TTL sweep ran — see [`PRUNE_INTERVAL`].
last_prune: Instant,
}
impl Default for Inner {
fn default() -> Self {
Self {
map: HashMap::new(),
last_prune: Instant::now(),
}
}
}
/// Tracks per-MAC driver-mode escalation. Cheap to share via `Arc`.
#[derive(Debug, Default)]
pub struct DriverEscalation {
inner: Mutex<Inner>,
/// Persistence target for learned modes; `None` = ephemeral (tests).
path: Option<Arc<PathBuf>>,
}
impl DriverEscalation {
/// Ephemeral instance (no persistence) — used by tests.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Instance backed by `<work_dir>/driver_modes.json`. Learned modes
/// from previous runs are reloaded as pinned entries; a missing or
/// corrupt file starts empty (same crash-cache policy as every other
/// store — a bad file must never block PXE).
#[must_use]
pub fn load_or_default(work_dir: &Path) -> Self {
let path = work_dir.join("driver_modes.json");
let mut map = HashMap::new();
if let Ok(text) = std::fs::read_to_string(&path) {
match serde_json::from_str::<HashMap<String, DriverMode>>(&text) {
Ok(loaded) => {
let now = Instant::now();
for (mac, mode) in loaded {
// Firmware is the default — persisting it would be
// noise; tolerate it in the file but don't track it.
if mode == DriverMode::Firmware {
continue;
}
map.insert(
mac,
Entry {
mode,
awaiting_confirm: false,
pinned: true,
last_seen: now,
},
);
}
tracing::info!(
target: "openpxe::dhcp",
learned = map.len(),
"loaded learned driver modes"
);
}
Err(e) => {
tracing::warn!(
target: "openpxe::dhcp",
"driver_modes.json present but unreadable ({e}); starting empty"
);
}
}
}
Self {
inner: Mutex::new(Inner {
map,
last_prune: Instant::now(),
}),
path: Some(Arc::new(path)),
}
}
/// Decide the driver mode for a firmware (PXEClient/HTTPClient) boot from
/// `mac`. `primary` is true for the main DHCP DISCOVER (:67) and false for
/// the PXE Boot Server query (:4011); only the primary path drives
/// escalation, and only when it's clearly a *new* boot (outside the
/// same-boot debounce). The :4011 path just echoes the current mode.
pub fn mode_for_firmware_attempt(&self, mac: &str, primary: bool) -> DriverMode {
self.decide_at(mac, primary, Instant::now())
}
/// Record that `mac` completed the iPXE handoff (a DISCOVER carrying the
/// `iPXE` user-class). The mode we last served worked, so stop awaiting
/// confirmation, keep it sticky, and — for non-default modes — pin it to
/// disk so the machine never re-walks the ladder (v0.7.1).
pub fn mark_ipxe_success(&self, mac: &str) {
self.confirm_at(mac, Instant::now());
}
fn decide_at(&self, mac: &str, primary: bool, now: Instant) -> DriverMode {
let (mode, snapshot) = {
let mut g = self.inner.lock();
if now.duration_since(g.last_prune) >= PRUNE_INTERVAL {
g.map
.retain(|_, e| e.pinned || now.duration_since(e.last_seen) < ENTRY_TTL);
g.last_prune = now;
}
// Inline staleness check: an unpinned MAC whose entry outlived
// the TTL starts fresh even when the amortized sweep above
// hasn't caught it yet. Pinned entries never go stale.
if g.map
.get(mac)
.is_some_and(|e| !e.pinned && now.duration_since(e.last_seen) >= ENTRY_TTL)
{
g.map.remove(mac);
}
let mut newly_pinned = false;
let mode = match g.map.get_mut(mac) {
None => {
g.map.insert(
mac.to_owned(),
Entry {
mode: DriverMode::Firmware,
// Only the primary DISCOVER opens a confirmation window.
awaiting_confirm: primary,
pinned: false,
last_seen: now,
},
);
if g.map.len() > MAX_ENTRIES {
evict_oldest(&mut g.map);
}
DriverMode::Firmware
}
Some(entry) => {
let recent = now.duration_since(entry.last_seen) < SAME_BOOT_DEBOUNCE;
if primary && !recent {
// A genuinely new boot. If the previous attempt was
// never confirmed, the build we served failed → climb
// one rung: Firmware (firmware NIC stack) → Builtin
// (iPXE's own drivers) → Shim (signed shim+GRUB —
// covers Secure Boot firmware that downloads our
// unsigned iPXE but refuses to execute it). Shim is
// terminal and pins to disk: SB machines never emit
// an iPXE handoff from the signed menu, so reaching
// the rung *is* the durable knowledge.
if entry.awaiting_confirm {
entry.mode = match entry.mode {
DriverMode::Firmware => DriverMode::Builtin,
DriverMode::Builtin | DriverMode::Shim => DriverMode::Shim,
};
if entry.mode == DriverMode::Shim && !entry.pinned {
entry.pinned = true;
newly_pinned = true;
}
}
entry.awaiting_confirm = true;
}
entry.last_seen = now;
entry.mode
}
};
(mode, newly_pinned.then(|| pinned_snapshot(&g.map)))
};
if let Some(s) = snapshot {
self.persist(&s);
}
mode
}
fn confirm_at(&self, mac: &str, now: Instant) {
let snapshot = {
let mut g = self.inner.lock();
let Some(e) = g.map.get_mut(mac) else {
return;
};
e.awaiting_confirm = false;
e.last_seen = now;
// A proven non-default mode is worth remembering forever —
// the machine demonstrably can't use the default path.
if e.mode != DriverMode::Firmware && !e.pinned {
e.pinned = true;
Some(pinned_snapshot(&g.map))
} else {
None
}
};
if let Some(s) = snapshot {
self.persist(&s);
}
}
/// Best-effort atomic write of the learned-mode table. No-op for
/// ephemeral instances. Failure logs and moves on — persistence is an
/// optimization, never a correctness requirement.
fn persist(&self, snapshot: &HashMap<String, DriverMode>) {
let Some(path) = &self.path else { return };
let body = match serde_json::to_vec_pretty(snapshot) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::dhcp", "serialize driver_modes.json: {e}");
return;
}
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::dhcp", "write driver_modes.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, path.as_path()) {
tracing::warn!(target: "openpxe::dhcp", "rename driver_modes.json: {e}");
}
}
}
fn pinned_snapshot(map: &HashMap<String, Entry>) -> HashMap<String, DriverMode> {
map.iter()
.filter(|(_, e)| e.pinned)
.map(|(k, e)| (k.clone(), e.mode))
.collect()
}
fn evict_oldest(map: &mut HashMap<String, Entry>) {
// Prefer evicting an unpinned entry; only touch learned modes when
// the whole table is pinned (4096 learned machines — at that point
// the operator has bigger questions than our memory bound).
let pick = |pinned: bool| {
map.iter()
.filter(|(_, e)| e.pinned == pinned)
.min_by_key(|(_, e)| e.last_seen)
.map(|(k, _)| k.clone())
};
if let Some(oldest) = pick(false).or_else(|| pick(true)) {
map.remove(&oldest);
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn firmware_first_then_escalates_on_unconfirmed_retry() {
let e = DriverEscalation::new();
let t0 = Instant::now();
// Boot 1, primary DISCOVER: firmware.
assert_eq!(e.decide_at("aa", true, t0), DriverMode::Firmware);
// Same boot's :4011 query (+1s, within debounce): still firmware, no escalation.
assert_eq!(
e.decide_at("aa", false, t0 + Duration::from_secs(1)),
DriverMode::Firmware
);
// Firmware net failed → no iPXE handoff → machine re-PXE-boots much
// later: escalate to builtin drivers.
assert_eq!(
e.decide_at("aa", true, t0 + Duration::from_mins(1)),
DriverMode::Builtin
);
}
#[test]
fn builtin_is_sticky_after_success() {
let e = DriverEscalation::new();
let t0 = Instant::now();
assert_eq!(e.decide_at("bb", true, t0), DriverMode::Firmware);
assert_eq!(
e.decide_at("bb", true, t0 + Duration::from_mins(1)),
DriverMode::Builtin
);
// Builtin worked this time — confirm the handoff.
e.confirm_at("bb", t0 + Duration::from_secs(61));
// Next cold boot goes straight to builtin (no wasted firmware attempt).
assert_eq!(
e.decide_at("bb", true, t0 + Duration::from_mins(2)),
DriverMode::Builtin
);
}
#[test]
fn confirmed_firmware_never_escalates() {
let e = DriverEscalation::new();
let t0 = Instant::now();
assert_eq!(e.decide_at("cc", true, t0), DriverMode::Firmware);
// snponly worked: handoff confirmed.
e.confirm_at("cc", t0 + Duration::from_secs(2));
// A later boot stays on firmware — no spurious escalation.
assert_eq!(
e.decide_at("cc", true, t0 + Duration::from_mins(5)),
DriverMode::Firmware
);
}
#[test]
fn third_unconfirmed_attempt_escalates_to_shim_and_stays() {
// v0.7.0: a Secure-Boot client downloads-but-refuses both unsigned
// iPXE builds; the third boot gets the signed shim chain, and the
// MAC stays there for subsequent boots.
let e = DriverEscalation::new();
let t0 = Instant::now();
assert_eq!(e.decide_at("ee", true, t0), DriverMode::Firmware);
assert_eq!(
e.decide_at("ee", true, t0 + Duration::from_mins(1)),
DriverMode::Builtin
);
assert_eq!(
e.decide_at("ee", true, t0 + Duration::from_mins(2)),
DriverMode::Shim
);
// Shim is terminal — a fourth unconfirmed boot stays on Shim.
assert_eq!(
e.decide_at("ee", true, t0 + Duration::from_mins(3)),
DriverMode::Shim
);
}
#[test]
fn stale_unpinned_entry_is_forgotten_and_resets_to_firmware() {
let e = DriverEscalation::new();
let t0 = Instant::now();
assert_eq!(e.decide_at("dd", true, t0), DriverMode::Firmware);
assert_eq!(
e.decide_at("dd", true, t0 + Duration::from_mins(1)),
DriverMode::Builtin
);
// After the TTL with no activity the (unpinned) Builtin walk is
// pruned → fresh firmware. (A *confirmed* Builtin would be pinned
// and survive — see learned_builtin_survives_ttl.)
let later = t0 + Duration::from_mins(1) + ENTRY_TTL + Duration::from_secs(1);
assert_eq!(e.decide_at("dd", true, later), DriverMode::Firmware);
}
#[test]
fn shim_pin_survives_ttl() {
// v0.7.1: reaching the Shim rung is durable knowledge — the
// machine must NOT re-walk the ladder after an idle period.
let e = DriverEscalation::new();
let t0 = Instant::now();
let _ = e.decide_at("ff", true, t0);
let _ = e.decide_at("ff", true, t0 + Duration::from_mins(1));
assert_eq!(
e.decide_at("ff", true, t0 + Duration::from_mins(2)),
DriverMode::Shim
);
let much_later = t0 + Duration::from_mins(2) + ENTRY_TTL + Duration::from_mins(5);
assert_eq!(e.decide_at("ff", true, much_later), DriverMode::Shim);
}
#[test]
fn learned_builtin_survives_ttl() {
let e = DriverEscalation::new();
let t0 = Instant::now();
let _ = e.decide_at("gg", true, t0);
assert_eq!(
e.decide_at("gg", true, t0 + Duration::from_mins(1)),
DriverMode::Builtin
);
// The handoff confirms Builtin → pinned.
e.confirm_at("gg", t0 + Duration::from_secs(61));
let much_later = t0 + ENTRY_TTL + Duration::from_mins(10);
assert_eq!(e.decide_at("gg", true, much_later), DriverMode::Builtin);
}
#[test]
fn learned_modes_persist_across_restart() {
let dir = tempdir().unwrap();
let t0 = Instant::now();
{
let e = DriverEscalation::load_or_default(dir.path());
// Walk one MAC to Shim (pins on escalation)...
let _ = e.decide_at("aa:01", true, t0);
let _ = e.decide_at("aa:01", true, t0 + Duration::from_mins(1));
assert_eq!(
e.decide_at("aa:01", true, t0 + Duration::from_mins(2)),
DriverMode::Shim
);
// ...and another to a confirmed Builtin (pins on handoff).
let _ = e.decide_at("aa:02", true, t0);
let _ = e.decide_at("aa:02", true, t0 + Duration::from_mins(1));
e.confirm_at("aa:02", t0 + Duration::from_secs(61));
}
// "Restart": a fresh instance from the same work_dir knows both.
let e2 = DriverEscalation::load_or_default(dir.path());
assert_eq!(e2.decide_at("aa:01", true, t0), DriverMode::Shim);
assert_eq!(e2.decide_at("aa:02", true, t0), DriverMode::Builtin);
// Unlearned MACs still start at the default.
assert_eq!(e2.decide_at("aa:03", true, t0), DriverMode::Firmware);
}
#[test]
fn corrupt_persistence_file_starts_empty() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("driver_modes.json"), b"{broken").unwrap();
let e = DriverEscalation::load_or_default(dir.path());
assert_eq!(
e.decide_at("aa:bb", true, Instant::now()),
DriverMode::Firmware
);
}
#[test]
fn confirmed_firmware_is_not_persisted() {
// The default mode is never written — the file only carries
// exceptions, so a healthy fleet leaves it absent/empty.
let dir = tempdir().unwrap();
let t0 = Instant::now();
{
let e = DriverEscalation::load_or_default(dir.path());
let _ = e.decide_at("aa:09", true, t0);
e.confirm_at("aa:09", t0 + Duration::from_secs(2));
}
assert!(!dir.path().join("driver_modes.json").exists());
}
}
-2
View File
@@ -17,9 +17,7 @@
//! clients silently drop them.
#![forbid(unsafe_code)]
pub mod escalation;
pub mod reply;
pub mod server;
pub use escalation::DriverEscalation;
pub use server::DhcpProxyServer;
+8 -23
View File
@@ -9,7 +9,7 @@
//! pass, or the HTTP URL of the boot script once iPXE has chained.
use dhcproto::v4::{DhcpOption, Message, MessageType, Opcode, OptionCode};
use openpxe_core::{ClientArch, DriverMode, FirmwareClass};
use openpxe_core::{ClientArch, FirmwareClass};
use std::net::Ipv4Addr;
/// Where the reply directs the client next.
@@ -31,11 +31,6 @@ pub struct ReplyContext<'a> {
pub our_ip: Ipv4Addr,
pub arch: ClientArch,
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.
pub public_base_url: &'a str,
}
@@ -49,27 +44,17 @@ pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
// Pass the client's MAC in the query string so the HTTP
// layer can short-circuit to a per-MAC binding when one
// exists. iPXE substitutes `${mac}` literally before issuing
// the GET, so this stays static across firmwares. The arch is
// known *here* from option 93, so it's baked in literally
// (v0.7.0) — it lets boot rules select on architecture.
// the GET, so this stays static across firmwares.
url: format!(
"{}/boot.ipxe?mac=${{mac}}&arch={}",
ctx.public_base_url.trim_end_matches('/'),
ctx.arch.as_str()
"{}/boot.ipxe?mac=${{mac}}",
ctx.public_base_url.trim_end_matches('/')
),
},
FirmwareClass::HttpClient => {
// UEFI HTTP boot: client wants an http:// URL in option 67
// pointing at an EFI executable. We serve the iPXE EFI build for
// the negotiated driver mode over HTTP; it'll then do the same
// 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");
// pointing at an EFI executable. We serve ipxe.efi over HTTP;
// it'll then do the same script-fetch the iPXE path does.
let name = ctx.arch.ipxe_bootfile().unwrap_or("snponly.efi");
BootDirective::HttpScript {
url: format!(
"{}/ipxe/{}",
@@ -78,7 +63,7 @@ pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
),
}
}
FirmwareClass::PxeClient => match ctx.arch.bootfile_with_fallback(ctx.driver_mode) {
FirmwareClass::PxeClient => match ctx.arch.ipxe_bootfile() {
Some(name) => BootDirective::TftpIpxe {
filename: name.to_string(),
},
+7 -56
View File
@@ -1,13 +1,10 @@
//! 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.
use crate::escalation::DriverEscalation;
use crate::reply::{build_reply, decide, BootDirective, ReplyContext};
use dhcproto::v4::{DhcpOption, Message, OptionCode};
use dhcproto::{Decodable, Decoder, Encodable, Encoder};
use openpxe_core::{
BootRulesStore, ClientArch, ClientEvent, ClientRegistry, DriverMode, FirmwareClass,
};
use openpxe_core::{ClientArch, ClientEvent, ClientRegistry, FirmwareClass};
use socket2::{Domain, Protocol, Socket, Type};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use std::sync::Arc;
@@ -21,19 +18,9 @@ pub struct DhcpProxyServer {
public_base_url: String,
clients: Arc<ClientRegistry>,
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 {
#[allow(clippy::too_many_arguments)]
pub fn new(
bind: IpAddr,
dhcp_port: u16,
@@ -42,8 +29,6 @@ impl DhcpProxyServer {
public_base_url: String,
clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
escalation: DriverEscalation,
rules: BootRulesStore,
) -> Self {
Self {
bind,
@@ -53,8 +38,6 @@ impl DhcpProxyServer {
public_base_url,
clients,
metrics,
escalation,
rules,
}
}
@@ -143,38 +126,11 @@ impl DhcpProxyServer {
},
);
// Automatic NIC driver-mode selection (v0.6.1). The default is
// firmware-net (snponly/undionly). A successful iPXE handoff confirms
// the current mode works for this MAC; a fresh firmware boot whose
// predecessor never handed off escalates the MAC to iPXE's built-in
// NIC drivers. No operator toggle — the firmware path is unchanged so
// hardware that already boots never regresses.
let driver_mode = match class {
FirmwareClass::IpxeUserClass => {
self.escalation.mark_ipxe_success(&mac);
DriverMode::Firmware // unused: this path serves the HTTP script
}
FirmwareClass::PxeClient | FirmwareClass::HttpClient => {
// v0.7.1: an operator rule pin wins over (and bypasses)
// the automatic escalation ladder — known Secure-Boot
// fleets boot the signed chain on the very first cycle.
if let Some(pinned) = self.rules.driver_mode_hint(&mac, Some(arch.as_str())) {
pinned
} else {
self.escalation
.mode_for_firmware_attempt(&mac, label == "67")
}
}
// Unreachable: FirmwareClass::Other returned above.
FirmwareClass::Other => DriverMode::Firmware,
};
let ctx = ReplyContext {
request: &request,
our_ip: self.our_ip,
arch,
class,
driver_mode,
public_base_url: &self.public_base_url,
};
let directive = decide(&ctx);
@@ -198,7 +154,7 @@ impl DhcpProxyServer {
sock.send_to(&out, dest).await?;
tracing::info!(
target: "openpxe::dhcp",
mac=%mac, arch=arch.as_str(), class=?class, driver=?driver_mode, dest=%dest, directive=?directive,
mac=%mac, arch=arch.as_str(), class=?class, dest=%dest, directive=?directive,
"PXE reply sent"
);
Ok(())
@@ -257,16 +213,11 @@ fn bind_udp(bind: IpAddr, port: u16, broadcast: bool) -> anyhow::Result<UdpSocke
}
fn format_mac(chaddr: &[u8]) -> String {
use std::fmt::Write;
// One allocation — this runs for every PXE datagram we answer.
let mut s = String::with_capacity(17);
for (i, b) in chaddr.iter().take(6).enumerate() {
if i > 0 {
s.push(':');
}
let _ = write!(s, "{b:02x}");
}
s
let take = chaddr.iter().take(6).copied().collect::<Vec<_>>();
take.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(":")
}
/// Walk raw DHCP options looking for option 93 (Client System Architecture)
-4
View File
@@ -49,10 +49,6 @@ base64.workspace = true
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] }
tower = { workspace = true }
tempfile = "3.12"
# v0.7.4: probe-based introspection verifies kernel paths against the
# real ISO9660 tree, so the full-flow tests synthesize images with the
# shared test builder instead of label-only blobs.
openpxe-iso-store = { workspace = true, features = ["test-image"] }
serde_json = { workspace = true }
time = { workspace = true }
# v0.4.61: integration tests need to generate real PNG bytes for the
+112 -477
View File
@@ -19,6 +19,7 @@ use crate::ipxe_script::{
render_entry, render_family_menu, render_local_hdd, render_menu, render_nic_info,
render_queue_entry, render_shell, render_tools_menu, render_util,
};
use crate::iso_fs;
use crate::log_stream;
use crate::state::AppState;
use crate::terminal;
@@ -34,8 +35,7 @@ use openpxe_core::{
encoding::pct_encode, ext_for_mime, wol, BootEvent, ClientEvent, DeployProfile, Error,
LogoSlot, NotifyConfig, Settings, SsoConfig, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
};
use openpxe_ipxe_assets::asset_slice;
use openpxe_iso_store::iso_fs;
use openpxe_ipxe_assets::asset_bytes;
use openpxe_iso_store::{
render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SftpAddRequest, SmbAddRequest,
SmbState, UnattendedKind, UnattendedMeta,
@@ -71,7 +71,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/branding/pxe-logo", get(ui_pxe_logo))
// iPXE script endpoints.
.route("/boot.ipxe", get(boot_top_menu))
.route("/boot/{filename}", get(boot_sub))
.route("/boot/:filename", get(boot_sub))
// v0.5.2: unattended answer-file *serving* — public (like /iso),
// because the booting installer fetches these with no session.
// `/unattended/:id` serves a Kickstart/Preseed with `{{HOSTNAME}}`
@@ -80,12 +80,12 @@ pub fn build_router(state: AppState) -> Router {
// autoinstall (`…/<ctx>/user-data` + `/meta-data`), where `<ctx>`
// base64url-encodes the per-host hostname/ip/mac. Management
// (upload/list/delete) lives under the gated `/api/unattended`.
.route("/unattended/{id}", get(serve_unattended))
.route("/unattended/{id}/{ctx}/{sub}", get(serve_unattended_seed))
.route("/unattended/:id", get(serve_unattended))
.route("/unattended/:id/:ctx/:sub", get(serve_unattended_seed))
// Bundled binaries and raw ISO access.
.route("/ipxe/{name}", get(ipxe_binary))
.route("/iso/{filename}", get(iso_raw))
.route("/iso/{id}/{*path}", get(iso_file))
.route("/ipxe/:name", get(ipxe_binary))
.route("/iso/:filename", get(iso_raw))
.route("/iso/:id/*path", get(iso_file))
// Container health/readiness probes. `/healthz` is always 200 OK
// while the HTTP task is alive. `/readyz` additionally requires at
// least one bundled iPXE binary (without one, no client can PXE).
@@ -93,23 +93,23 @@ pub fn build_router(state: AppState) -> Router {
.route("/readyz", get(readyz))
// JSON API.
.route("/api/isos", get(api_list_isos).post(api_upload_iso))
.route("/api/isos/{id}", delete(api_delete_iso))
.route("/api/isos/:id", delete(api_delete_iso))
.route("/api/uploads", post(api_upload_begin))
.route(
"/api/uploads/{upload_id}",
"/api/uploads/:upload_id",
put(api_upload_chunk).delete(api_upload_abort),
)
// Per-ISO password prompt. PUT body `{ "password": "..." }`
// sets, `{ "password": null }` (or DELETE) clears.
.route(
"/api/isos/{id}/password",
"/api/isos/:id/password",
axum::routing::put(api_set_iso_password).delete(api_clear_iso_password),
)
// v0.4.4: per-ISO menu category (Os / Tools). Drives whether the
// image appears under Linux/Windows Installers (default) or in
// the Tools submenu next to memtest / shell / NIC info.
.route(
"/api/isos/{id}/category",
"/api/isos/:id/category",
axum::routing::put(api_set_iso_category),
)
// v0.4.4: filesystem free-space telemetry for the ISO directory's
@@ -120,7 +120,7 @@ pub fn build_router(state: AppState) -> Router {
// logo). v0.5.2: split into three slots — `light` / `dark` /
// `client`. Multipart upload to POST; DELETE clears one slot.
.route(
"/api/branding/logo/{slot}",
"/api/branding/logo/:slot",
post(api_branding_upload).delete(api_branding_clear),
)
// v0.5.2: unattended-install answer-file management (gated).
@@ -130,7 +130,7 @@ pub fn build_router(state: AppState) -> Router {
"/api/unattended",
get(api_unattended_list).post(api_unattended_upload),
)
.route("/api/unattended/{id}", delete(api_unattended_delete))
.route("/api/unattended/:id", delete(api_unattended_delete))
// v0.4.4: self-rendered API reference, served as JSON so the UI
// can format it consistently with the rest of the chrome. Lives
// under the Settings tab — operators chasing an integration get
@@ -161,12 +161,12 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/settings", get(api_get_settings).put(api_put_settings))
.route("/api/queue", get(api_list_queue))
.route("/api/queue/join", get(api_queue_join))
.route("/api/queue/poll/{entry_id}", get(api_queue_poll))
.route("/api/queue/poll/:entry_id", get(api_queue_poll))
.route("/api/queue/assign", post(api_queue_assign))
// v0.5.2: per-device deployment profile (auto hostname / IP /
// unattended file) set from the queue "Profile" button.
.route("/api/queue/{entry_id}/profile", put(api_queue_set_profile))
.route("/api/queue/{entry_id}", delete(api_queue_release))
.route("/api/queue/:entry_id/profile", put(api_queue_set_profile))
.route("/api/queue/:entry_id", delete(api_queue_release))
// v0.4.65: SMB share manager (userspace via smbclient). The
// kernel-mount NFS routes that v0.4.64 shipped are gone — they
// didn't work on hosts whose kernel lacked the nfs client
@@ -177,8 +177,8 @@ pub fn build_router(state: AppState) -> Router {
"/api/smb-shares",
get(api_smb_shares_list).post(api_smb_shares_add),
)
.route("/api/smb-shares/{id}", delete(api_smb_shares_remove))
.route("/api/smb-shares/{id}/scan", post(api_smb_shares_scan))
.route("/api/smb-shares/:id", delete(api_smb_shares_remove))
.route("/api/smb-shares/:id/scan", post(api_smb_shares_scan))
// v0.4.67: NFSv3 share manager (pure-Rust in-process client).
// Ships alongside SMB. Routes are parallel so the UI can
// reuse the same form/error/hint rendering for both.
@@ -186,8 +186,8 @@ pub fn build_router(state: AppState) -> Router {
"/api/nfs-shares",
get(api_nfs_shares_list).post(api_nfs_shares_add),
)
.route("/api/nfs-shares/{id}", delete(api_nfs_shares_remove))
.route("/api/nfs-shares/{id}/scan", post(api_nfs_shares_scan))
.route("/api/nfs-shares/:id", delete(api_nfs_shares_remove))
.route("/api/nfs-shares/:id/scan", post(api_nfs_shares_scan))
// v0.5.5: SFTP-over-SSH share manager (pure-Rust russh client).
// Parallel to SMB/NFS so the UI reuses the same form/error/hint
// rendering. Like NFS, SFTP-sourced ISOs support Range requests.
@@ -195,8 +195,8 @@ pub fn build_router(state: AppState) -> Router {
"/api/sftp-shares",
get(api_sftp_shares_list).post(api_sftp_shares_add),
)
.route("/api/sftp-shares/{id}", delete(api_sftp_shares_remove))
.route("/api/sftp-shares/{id}/scan", post(api_sftp_shares_scan))
.route("/api/sftp-shares/:id", delete(api_sftp_shares_remove))
.route("/api/sftp-shares/:id/scan", post(api_sftp_shares_scan))
// Phase 4: Network info (read-only) + DNS edit.
.route("/api/network", get(api_network).put(api_network_put))
// Phase 4: live-log stream + recent buffer for the Terminal tab.
@@ -208,17 +208,10 @@ pub fn build_router(state: AppState) -> Router {
// Phase 5: per-MAC host bindings. Operator
// pins a MAC to a boot entry; /boot.ipxe?mac=... chains directly.
.route("/api/hosts", get(api_hosts_list).post(api_hosts_upsert))
.route("/api/hosts/{mac}", delete(api_hosts_remove))
.route("/api/hosts/:mac", delete(api_hosts_remove))
// v0.5.0: Wake-on-LAN a bound host. Sends a magic packet to the
// limited broadcast + the server's own subnet broadcast.
.route("/api/hosts/{mac}/wol", post(api_hosts_wol))
// v0.7.0: ordered boot rules (MAC prefix / arch → target) + the
// boot-decision webhook. The UI saves the whole config at once
// because rule order is significant.
.route(
"/api/boot-rules",
get(api_boot_rules_get).put(api_boot_rules_put),
)
.route("/api/hosts/:mac/wol", post(api_hosts_wol))
// Rolling "host log" of boot events: what image actually
// started installing on what MAC/IP, and when. Persisted to disk.
.route("/api/boot-log", get(api_boot_log))
@@ -413,22 +406,6 @@ fn bundled_logo_response() -> Response {
/// brand mark falls back to the *default* background for the PXE screen
/// (the WebUI still renders the SVG natively in the top-left).
async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
// The composite is a pure function of the uploaded logo, so the
// encoded PNG is cached keyed on the branding revision — an upload
// or clear bumps the rev and invalidates it. The response headers
// stay `no-cache` (clients must refetch); only the server-side
// ~50-200 ms decode/compose/encode is skipped per boot.
let rev = state.branding.logo_rev();
let cached = state
.pxe_bg_cache
.lock()
.as_ref()
.filter(|(r, _)| *r == rev)
.map(|(_, png)| png.clone());
if let Some(png) = cached {
return pxe_png_response(png);
}
// Resolve the operator's raster upload, if any and if it's a format
// iPXE/our compositor can consume. SVG (or a missing/unreadable
// file) yields `None`, which composes the default background.
@@ -473,12 +450,6 @@ async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
.into_response();
}
};
let png = bytes::Bytes::from(composed);
*state.pxe_bg_cache.lock() = Some((rev, png.clone()));
pxe_png_response(png)
}
fn pxe_png_response(png: bytes::Bytes) -> Response {
(
[
(header::CONTENT_TYPE, HeaderValue::from_static("image/png")),
@@ -489,7 +460,7 @@ fn pxe_png_response(png: bytes::Bytes) -> Response {
HeaderValue::from_static("no-cache, max-age=0"),
),
],
png,
composed,
)
.into_response()
}
@@ -527,15 +498,14 @@ fn text_plain(body: String) -> Response {
/// to the bound target instead of rendering the menu.
async fn boot_top_menu(
State(state): State<AppState>,
peer: Result<ConnectInfo<SocketAddr>, axum::extract::rejection::ExtensionRejection>,
peer: Option<ConnectInfo<SocketAddr>>,
Query(p): Query<BootMenuParams>,
) -> Response {
// `ConnectInfo` is only populated when axum was started with
// `into_make_service_with_connect_info` (production path). Tests
// call the router via `oneshot`, which skips that wiring — we
// tolerate it by treating the peer as unknown rather than 500ing.
// (axum 0.8: `Result<T, Rejection>` is the optional-extractor form.)
let peer_ip = peer.ok().map(|c| c.0.ip());
let peer_ip = peer.map(|c| c.0.ip());
state
.metrics
.record_http(openpxe_core::HttpRoute::BootScript);
@@ -577,105 +547,17 @@ async fn boot_top_menu(
// `?mac=` so the per-entry handler can record the boot into
// the Host log without depending on iPXE substitution at
// this stage.
return text_plain(chain_script(base, &target, &bound_mac, "per-MAC binding"));
}
// v0.7.0 step 2: ordered boot rules (MAC prefix / arch).
let mac_norm = openpxe_core::normalize_mac(mac);
if let Some(target) = state.boot_rules.match_target(&mac_norm, p.arch.as_deref()) {
tracing::info!(
target: "openpxe::http",
mac = %mac_norm, target = %target, "boot rule matched"
);
record_pre_boot(&state, &isos, &mac_norm, peer_ip, &target);
return text_plain(chain_script(base, &target, &mac_norm, "boot rule"));
}
// v0.7.0 step 3: boot-decision webhook (fail-open — any error,
// timeout, or non-200 falls through to the menu so a dead
// automation endpoint can never block PXE for the network).
if let Some(url) = state.boot_rules.webhook_url() {
if let Some(target) = webhook_decide(&url, &mac_norm, p.arch.as_deref()).await {
tracing::info!(
target: "openpxe::http",
mac = %mac_norm, target = %target, "boot webhook decided"
);
record_pre_boot(&state, &isos, &mac_norm, peer_ip, &target);
return text_plain(chain_script(base, &target, &mac_norm, "boot webhook"));
}
return text_plain(format!(
"#!ipxe\n\
echo OpenPXE: per-MAC binding -> {target}\n\
chain {base}/boot/{target}.ipxe?mac={bound_mac} || chain {base}/boot.ipxe\n"
));
}
}
text_plain(render_menu(&isos, &settings, base))
}
/// The short-circuit script all three decision sources (binding, rule,
/// webhook) emit: chain to the target's boot script, falling back to the
/// interactive menu so a stale target can't lock a client out.
fn chain_script(base: &str, target: &str, mac: &str, source: &str) -> String {
format!(
"#!ipxe\n\
echo OpenPXE: {source} -> {target}\n\
chain {base}/boot/{target}.ipxe?mac={mac} || chain {base}/boot.ipxe\n"
)
}
/// Pre-record a decision-driven boot into the Host log, mirroring what
/// the per-MAC binding path does: reserved `_xxx` targets are operator
/// conveniences, not imaging events, so they're skipped.
fn record_pre_boot(
state: &AppState,
isos: &[openpxe_iso_store::IsoMeta],
mac: &str,
peer_ip: Option<std::net::IpAddr>,
target: &str,
) {
if target.starts_with('_') {
return;
}
let title = lookup_entry_title(isos, target);
state.boot_log.record(&BootEvent {
timestamp: time::OffsetDateTime::now_utc(),
mac: Some(mac.to_string()),
ip: peer_ip,
target_id: target.to_string(),
target_title: title,
});
}
/// Ask the operator's boot-decision webhook for a target. `200` with
/// `{"target": "<id>"}` chains to that target; anything else (including
/// an empty target) means "no opinion". Two-second budget — a booting
/// machine is sitting at a black screen while this runs.
async fn webhook_decide(url: &str, mac: &str, arch: Option<&str>) -> Option<String> {
#[derive(Deserialize)]
struct Decision {
target: String,
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.build()
.ok()?;
let resp = match client
.get(url)
.query(&[("mac", mac), ("arch", arch.unwrap_or(""))])
.send()
.await
{
Ok(r) => r,
Err(e) => {
tracing::warn!(target: "openpxe::http", "boot webhook unreachable: {e}");
return None;
}
};
if !resp.status().is_success() {
return None;
}
let d: Decision = resp.json().await.ok()?;
let t = d.target.trim().to_string();
(!t.is_empty()).then_some(t)
}
/// Best-effort human title for a boot entry id — falls back to the id
/// itself if the ISO has been deleted between record-time and now.
fn lookup_entry_title(isos: &[openpxe_iso_store::IsoMeta], target_id: &str) -> String {
@@ -698,11 +580,6 @@ struct BootMenuParams {
/// `chain ${prefix}/boot.ipxe?mac=${mac}`. Optional — if absent we
/// fall back to the menu unconditionally.
mac: Option<String>,
/// v0.7.0: client architecture (`ClientArch::as_str()` form), baked
/// literally into the chain URL by the DHCP proxy, which knows it
/// from option 93. Lets boot rules select on architecture. Absent on
/// chains rendered by older binaries — arch rules simply don't match.
arch: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -720,12 +597,11 @@ struct BootSubParams {
async fn boot_sub(
State(state): State<AppState>,
peer: Result<ConnectInfo<SocketAddr>, axum::extract::rejection::ExtensionRejection>,
peer: Option<ConnectInfo<SocketAddr>>,
AxumPath(filename): AxumPath<String>,
Query(p): Query<BootSubParams>,
) -> Response {
// axum 0.8: `Result<T, Rejection>` is the optional-extractor form.
let peer_ip = peer.ok().map(|c| c.0.ip());
let peer_ip = peer.map(|c| c.0.ip());
// `/boot/<name>.ipxe` where `<name>` is either one of our reserved
// submenu names (prefixed `_`) or a boot entry id.
let name = filename.strip_suffix(".ipxe").unwrap_or(&filename);
@@ -762,23 +638,7 @@ async fn boot_sub(
));
}
Some(token) => {
// bcrypt verify costs ~100-200 ms of pure
// CPU and this path is unauthenticated —
// run it on the blocking pool so password
// probes can't stall the workers that are
// streaming ISO bytes to imaging machines.
let store = state.iso_store.clone();
let iso_id = iso.id.clone();
let tok = token.to_string();
let verdict = match tokio::task::spawn_blocking(move || {
store.verify_password(&iso_id, &tok)
})
.await
{
Ok(v) => v,
Err(e) => Err(openpxe_core::Error::Other(e.into())),
};
match verdict {
match state.iso_store.verify_password(&iso.id, token) {
Ok(true) => { /* fall through to render the entry */ }
Ok(false) => {
// Don't log the candidate — just the
@@ -850,13 +710,7 @@ async fn boot_sub(
.as_deref()
.and_then(|fid| state.unattended.get(fid))
.and_then(|meta| {
build_unattended_args(
base,
&meta,
Some(m),
&p,
&state.boot_tokens,
)
build_unattended_args(base, &meta, Some(m), &p)
})
})
});
@@ -877,28 +731,13 @@ async fn boot_sub(
// ─── bundled iPXE binaries (memtest lives here too) ───────────────────────
async fn ipxe_binary(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
async fn ipxe_binary(AxumPath(name): AxumPath<String>) -> Response {
if name.contains('/') || name.contains('\\') {
return (StatusCode::BAD_REQUEST, "invalid name").into_response();
}
// v0.7.0: when the whole Secure Boot chain rides HTTP (native UEFI
// HTTP Boot), GRUB resolves `$prefix` to this directory and fetches
// its config from here — rendered live, same as the TFTP path.
if name == "grub.cfg" || name.starts_with("grub.cfg-") {
return text_plain(crate::grub_script::render_grub_menu(
&state.iso_store.list(),
&state.public_base_url,
));
}
let Some(data) = asset_slice(&name) else {
let Some(bytes) = asset_bytes(&name) else {
return (StatusCode::NOT_FOUND, "no such ipxe asset").into_response();
};
// Release builds embed the asset in rodata — serve it without the
// ~1 MiB per-request heap copy `into_owned` would cost.
let bytes = match data {
std::borrow::Cow::Borrowed(b) => bytes::Bytes::from_static(b),
std::borrow::Cow::Owned(v) => bytes::Bytes::from(v),
};
(
[
(
@@ -929,10 +768,7 @@ async fn iso_raw(
};
match &meta.source {
IsoSource::Local => {
// `local_path(&meta)` reuses the meta we already cloned —
// `iso_path_for(id)` would re-lock and deep-clone it again,
// hundreds of times per sanboot install.
let Some(path) = state.iso_store.local_path(&meta) else {
let Some(path) = state.iso_store.iso_path_for(id) else {
return (StatusCode::NOT_FOUND, "no such iso").into_response();
};
match stream_file_range(&path, headers.get(header::RANGE)).await {
@@ -1087,105 +923,28 @@ async fn iso_file(
State(state): State<AppState>,
AxumPath((id, path)): AxumPath<(String, String)>,
) -> Response {
let Some(meta) = state.iso_store.get(&id) else {
// In-ISO file extraction is only supported for local ISOs — it
// needs random-access reads into the ISO9660 directory tree, which
// smbclient's whole-file streaming can't do efficiently. SMB-
// sourced ISOs use the raw streaming endpoint above instead.
let Some(iso_path) = state.iso_store.iso_path_for(&id) else {
return (StatusCode::NOT_FOUND, "no such iso").into_response();
};
let p = iso_path.clone();
let in_path = format!("/{path}");
match &meta.source {
IsoSource::Local => {
let Some(iso_path) = state.iso_store.local_path(&meta) else {
return (StatusCode::NOT_FOUND, "no such iso").into_response();
};
let p = iso_path.clone();
let loc = tokio::task::spawn_blocking(move || iso_fs::lookup_local(&p, &in_path))
.await
.ok()
.flatten();
let Some(loc) = loc else {
return (StatusCode::NOT_FOUND, "not found inside iso").into_response();
};
match stream_byte_range(&iso_path, loc.offset, loc.length).await {
Ok(r) => r,
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
}
// v0.7.4: remote ISOs serve in-ISO files too — the same ISO9660
// walk runs over NFS READ3 / SFTP seek-reads, then the located
// byte range streams through the share manager. This is what
// makes the verified kernel/initrd boot entries on share-hosted
// Linux ISOs actually bootable.
IsoSource::Nfs {
share_id,
relative_path,
} => {
match state
.nfs_shares
.locate_in_iso(share_id, relative_path, &in_path)
.await
{
Ok(Some(loc)) => {
match state
.nfs_shares
.stream_iso(share_id, relative_path, loc.offset, Some(loc.length))
.await
{
Ok(stream) => in_iso_stream_response(Body::from_stream(stream), loc.length),
Err(e) => {
(StatusCode::BAD_GATEWAY, format!("nfs stream: {e}")).into_response()
}
}
}
Ok(None) => (StatusCode::NOT_FOUND, "not found inside iso").into_response(),
Err(e) => (StatusCode::BAD_GATEWAY, format!("nfs lookup: {e}")).into_response(),
}
}
IsoSource::Sftp {
share_id,
relative_path,
} => {
match state
.sftp_shares
.locate_in_iso(share_id, relative_path, &in_path)
.await
{
Ok(Some(loc)) => {
match state
.sftp_shares
.stream_iso(share_id, relative_path, loc.offset, Some(loc.length))
.await
{
Ok(stream) => in_iso_stream_response(Body::from_stream(stream), loc.length),
Err(e) => {
(StatusCode::BAD_GATEWAY, format!("sftp stream: {e}")).into_response()
}
}
}
Ok(None) => (StatusCode::NOT_FOUND, "not found inside iso").into_response(),
Err(e) => (StatusCode::BAD_GATEWAY, format!("sftp lookup: {e}")).into_response(),
}
}
// smbclient streams sequentially — no seeks, no ISO9660 walk.
// SMB ISOs never emit kernel entries, so nothing requests this.
IsoSource::Smb { .. } => (
StatusCode::NOT_FOUND,
"in-ISO files are not available for SMB-sourced ISOs",
)
.into_response(),
let loc = tokio::task::spawn_blocking(move || iso_fs::lookup(&p, &in_path))
.await
.ok()
.flatten();
let Some(loc) = loc else {
return (StatusCode::NOT_FOUND, "not found inside iso").into_response();
};
match stream_byte_range(&iso_path, loc.offset, loc.length).await {
Ok(r) => r,
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
}
/// 200 response wrapping an in-ISO byte-range stream from a share
/// manager. Content-Length is the located file's length — the stream is
/// already bounded to exactly that range.
fn in_iso_stream_response(body: Body, length: u64) -> Response {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(header::CONTENT_LENGTH, length)
.body(body)
.unwrap()
}
async fn stream_file_range(
path: &std::path::Path,
range: Option<&HeaderValue>,
@@ -1268,25 +1027,15 @@ fn parse_range(h: Option<&HeaderValue>, total: u64) -> Option<(u64, u64, bool)>
return Some((total.saturating_sub(n), total.saturating_sub(1), true));
}
}
// RFC 7233 §3.1: a Range header we can't parse is *ignored* (200 +
// full body), never coerced into a bogus 206 claiming the whole
// file. Only `first-pos[-last-pos]` with numeric positions reaches
// the partial path; `None` is reserved for syntactically valid but
// unsatisfiable ranges (→ 416).
let full = Some((0, total.saturating_sub(1), false));
let Some((start_s, end_s)) = spec.split_once('-') else {
return full;
};
let Ok(start) = start_s.trim().parse::<u64>() else {
return full;
};
let end = if end_s.trim().is_empty() {
total.saturating_sub(1)
} else if let Ok(e) = end_s.trim().parse::<u64>() {
e
} else {
return full;
};
let mut parts = spec.splitn(2, '-');
let start = parts
.next()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let end = parts
.next()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(total.saturating_sub(1));
if start >= total {
return None;
}
@@ -1574,54 +1323,17 @@ struct UnattendedServeQuery {
hostname: Option<String>,
#[serde(default)]
ip: Option<String>,
/// v0.7.0: short-lived access token minted into the generated URL.
#[serde(default)]
t: Option<String>,
}
/// v0.7.0: answer files routinely embed credentials, so once an admin
/// account exists they're only served to (a) the boot that the URL was
/// minted for — proven by the token OpenPXE put in that URL — or (b) a
/// logged-in operator (browser testing). Pre-setup installs stay open,
/// matching the auth middleware's bootstrap behavior.
fn unattended_access_allowed(
state: &AppState,
headers: &HeaderMap,
token: Option<&str>,
file_id: &str,
) -> bool {
if !state.admin.is_configured() {
return true;
}
if token.is_some_and(|t| state.boot_tokens.check(t, file_id)) {
return true;
}
crate::auth::session_authenticated(state, headers)
}
fn unattended_denied() -> Response {
(
StatusCode::UNAUTHORIZED,
"answer files require the boot-scoped token OpenPXE mints into \
generated URLs (or an operator session)",
)
.into_response()
}
/// Serve a Kickstart/Preseed/answer file with `{{HOSTNAME}}` /
/// Public: serve a Kickstart/Preseed/answer file with `{{HOSTNAME}}` /
/// `{{IP}}` / `{{MAC}}` substituted from the query string. Returns
/// `text/plain` so installers (anaconda, debian-installer, Windows setup
/// fetching over HTTP) read it verbatim. Token-gated since v0.7.0 — see
/// [`unattended_access_allowed`].
/// fetching over HTTP) read it verbatim.
async fn serve_unattended(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
headers: HeaderMap,
Query(q): Query<UnattendedServeQuery>,
) -> Response {
if !unattended_access_allowed(&state, &headers, q.t.as_deref(), &id) {
return unattended_denied();
}
let Ok(bytes) = state.unattended.read(&id).await else {
return (StatusCode::NOT_FOUND, "no such unattended file").into_response();
};
@@ -1643,15 +1355,8 @@ async fn serve_unattended(
async fn serve_unattended_seed(
State(state): State<AppState>,
AxumPath((id, ctx, sub)): AxumPath<(String, String, String)>,
headers: HeaderMap,
) -> Response {
let (mac, hostname, ip, token) = decode_seed_ctx(&ctx);
// v0.7.0: the seed ctx carries the access token (the seedfrom URL
// can't take a query string). Same gate as the flat answer-file
// route — see `unattended_access_allowed`.
if !unattended_access_allowed(&state, &headers, token.as_deref(), &id) {
return unattended_denied();
}
let (mac, hostname, ip) = decode_seed_ctx(&ctx);
match sub.as_str() {
"user-data" => {
let Ok(bytes) = state.unattended.read(&id).await else {
@@ -1675,22 +1380,6 @@ async fn serve_unattended_seed(
}
}
// ─── Boot rules (v0.7.0) ───────────────────────────────────────────────────
async fn api_boot_rules_get(State(state): State<AppState>) -> Json<openpxe_core::BootRulesConfig> {
Json(state.boot_rules.snapshot())
}
async fn api_boot_rules_put(
State(state): State<AppState>,
Json(cfg): Json<openpxe_core::BootRulesConfig>,
) -> StatusCode {
let n = cfg.rules.len();
state.boot_rules.replace(cfg);
tracing::info!(target: "openpxe::http", rules = n, "boot rules replaced");
StatusCode::NO_CONTENT
}
/// Resolve the deployment profile for a booting MAC: a host pin wins, else
/// a queued device's Profile. `None` when neither carries one.
fn resolve_profile(state: &AppState, mac: &str) -> Option<DeployProfile> {
@@ -1710,23 +1399,12 @@ fn build_unattended_args(
meta: &UnattendedMeta,
mac: Option<&str>,
profile: &DeployProfile,
tokens: &openpxe_core::BootTokens,
) -> Option<String> {
let base = base.trim_end_matches('/');
let id = &meta.id;
let host = profile.auto_hostname.as_deref();
let ip = profile.auto_ip.as_deref();
// v0.7.0: every generated answer-file URL carries a fresh boot-scoped
// token; the serving endpoint requires it. See `crate::auth` and
// `openpxe_core::boot_tokens` for the threat model (CVE-2026-0386-
// style credential harvesting from openly-served answer files).
let token = tokens.mint(id);
let query = build_query(&[
("mac", mac),
("hostname", host),
("ip", ip),
("t", Some(&token)),
]);
let query = build_query(&[("mac", mac), ("hostname", host), ("ip", ip)]);
match meta.kind {
UnattendedKind::Kickstart => Some(format!("inst.ks={base}/unattended/{id}{query}")),
UnattendedKind::Preseed => {
@@ -1738,7 +1416,7 @@ fn build_unattended_args(
Some(s)
}
UnattendedKind::Autoinstall => {
let ctx = encode_seed_ctx(mac, host, ip, &token);
let ctx = encode_seed_ctx(mac, host, ip);
Some(format!(
"autoinstall ds=nocloud-net;s={base}/unattended/{id}/{ctx}/"
))
@@ -1763,19 +1441,12 @@ fn build_query(pairs: &[(&str, Option<&str>)]) -> String {
// `pct_encode` lives in `openpxe_core::encoding` (v0.5.4) — imported above.
/// Encode `(hostname, ip, mac, token)` into a single base64url path
/// segment for the cloud-init seed directory. Empty values become empty
/// fields. The access token rides in here (v0.7.0) because the
/// `seedfrom` URL can't carry a query string.
fn encode_seed_ctx(
mac: Option<&str>,
hostname: Option<&str>,
ip: Option<&str>,
token: &str,
) -> String {
/// Encode `(hostname, ip, mac)` into a single base64url path segment for
/// the cloud-init seed directory. Empty values become empty fields.
fn encode_seed_ctx(mac: Option<&str>, hostname: Option<&str>, ip: Option<&str>) -> String {
use base64::Engine as _;
let raw = format!(
"{}\n{}\n{}\n{token}",
"{}\n{}\n{}",
hostname.unwrap_or(""),
ip.unwrap_or(""),
mac.unwrap_or("")
@@ -1783,30 +1454,21 @@ fn encode_seed_ctx(
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes())
}
/// Inverse of [`encode_seed_ctx`]; returns `(mac, hostname, ip, token)`.
/// A bad or empty segment yields all-`None`; a pre-v0.7.0 three-field
/// ctx decodes with `token: None` (and the gate then rejects it once an
/// admin exists — stale URLs are exactly what tokens invalidate).
fn decode_seed_ctx(
ctx: &str,
) -> (
Option<String>,
Option<String>,
Option<String>,
Option<String>,
) {
/// Inverse of [`encode_seed_ctx`]; returns `(mac, hostname, ip)`. A bad
/// or empty segment yields all-`None` so the seed still serves (just
/// without per-host substitution).
fn decode_seed_ctx(ctx: &str) -> (Option<String>, Option<String>, Option<String>) {
use base64::Engine as _;
let Ok(bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(ctx.as_bytes()) else {
return (None, None, None, None);
return (None, None, None);
};
let s = String::from_utf8_lossy(&bytes).into_owned();
let mut it = s.splitn(4, '\n');
let mut it = s.splitn(3, '\n');
let clean = |v: Option<&str>| v.map(str::to_string).filter(|x| !x.is_empty());
let hostname = clean(it.next());
let ip = clean(it.next());
let mac = clean(it.next());
let token = clean(it.next());
(mac, hostname, ip, token)
(mac, hostname, ip)
}
// ─── API reference (Settings → bottom) ────────────────────────────────────
@@ -1841,13 +1503,13 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List ISOs (local + NFS) with size, family, boot entries, category."},
{"method": "POST", "path": "/api/isos",
"summary": "Legacy single-shot multipart upload. Prefer /api/uploads for big files."},
{"method": "DELETE", "path": "/api/isos/{id}",
{"method": "DELETE", "path": "/api/isos/:id",
"summary": "Delete a local ISO and its sidecar metadata."},
{"method": "PUT", "path": "/api/isos/{id}/password",
{"method": "PUT", "path": "/api/isos/:id/password",
"summary": "Set or update an ISO's boot password (bcrypt-hashed; plaintext never stored)."},
{"method": "DELETE", "path": "/api/isos/{id}/password",
{"method": "DELETE", "path": "/api/isos/:id/password",
"summary": "Clear an ISO's boot password."},
{"method": "PUT", "path": "/api/isos/{id}/category",
{"method": "PUT", "path": "/api/isos/:id/category",
"summary": "Set the menu category. Body: { \"category\": \"os\" | \"tools\" }."},
],
},
@@ -1856,9 +1518,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"endpoints": [
{"method": "POST", "path": "/api/uploads",
"summary": "Begin a chunked upload session. Body: { \"filename\", \"size_bytes\" }."},
{"method": "PUT", "path": "/api/uploads/{upload_id}",
{"method": "PUT", "path": "/api/uploads/:upload_id",
"summary": "Append a chunk. Headers: x-openpxe-upload-offset, x-openpxe-upload-complete."},
{"method": "DELETE", "path": "/api/uploads/{upload_id}",
{"method": "DELETE", "path": "/api/uploads/:upload_id",
"summary": "Abort a chunked upload session and remove the .partial file."},
],
},
@@ -1869,9 +1531,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List configured SMB shares with connection state and iso counts."},
{"method": "POST", "path": "/api/smb-shares",
"summary": "Register an SMB share. Body: { server, share, guest, username?, password?, port? }."},
{"method": "DELETE", "path": "/api/smb-shares/{id}",
{"method": "DELETE", "path": "/api/smb-shares/:id",
"summary": "Forget a share and drop its entries from the ISO store."},
{"method": "POST", "path": "/api/smb-shares/{id}/scan",
{"method": "POST", "path": "/api/smb-shares/:id/scan",
"summary": "Re-list a share for new ISOs."},
],
},
@@ -1882,9 +1544,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List configured NFSv3 shares with connection state and iso counts."},
{"method": "POST", "path": "/api/nfs-shares",
"summary": "Register an NFSv3 share. Body: { server, export, port? }. Auth is AUTH_SYS only; access control is by client IP on the server side."},
{"method": "DELETE", "path": "/api/nfs-shares/{id}",
{"method": "DELETE", "path": "/api/nfs-shares/:id",
"summary": "Forget a share and drop its entries from the ISO store."},
{"method": "POST", "path": "/api/nfs-shares/{id}/scan",
{"method": "POST", "path": "/api/nfs-shares/:id/scan",
"summary": "Re-list a share for new ISOs."},
],
},
@@ -1895,9 +1557,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List configured SFTP-over-SSH shares with connection state and iso counts."},
{"method": "POST", "path": "/api/sftp-shares",
"summary": "Register an SFTP share. Body: { server, export, username, port?, password? | private_key? + passphrase? }. The server's SSH host key is pinned trust-on-first-use."},
{"method": "DELETE", "path": "/api/sftp-shares/{id}",
{"method": "DELETE", "path": "/api/sftp-shares/:id",
"summary": "Forget a share, drop its entries from the ISO store, and scrub its credentials file."},
{"method": "POST", "path": "/api/sftp-shares/{id}/scan",
{"method": "POST", "path": "/api/sftp-shares/:id/scan",
"summary": "Re-list a share for new ISOs."},
],
},
@@ -1917,9 +1579,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "Current runtime settings (Windows toggle, timeout, dns hint, …)."},
{"method": "PUT", "path": "/api/settings",
"summary": "Replace runtime settings. Guards against enabling Windows when wimboot isn't bundled."},
{"method": "POST", "path": "/api/branding/logo/{slot}",
{"method": "POST", "path": "/api/branding/logo/:slot",
"summary": "Upload a custom logo for a slot (light | dark | client). Multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB. The client slot is raster-only."},
{"method": "DELETE", "path": "/api/branding/logo/{slot}",
{"method": "DELETE", "path": "/api/branding/logo/:slot",
"summary": "Remove the custom logo for a slot and revert to the bundled mark."},
{"method": "GET", "path": "/branding/pxe-logo",
"summary": "Raster form of the operator's 'client' logo for the iPXE menu's `console --picture`. Default background when unset/SVG."},
@@ -1960,9 +1622,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List uploaded answer files (Kickstart / Preseed / Autoinstall / Windows answer file)."},
{"method": "POST", "path": "/api/unattended",
"summary": "Upload an answer file (multipart 'file', .ks/.cfg/.seed/.yaml/.yml/.xml/user-data, up to 1 MB)."},
{"method": "DELETE", "path": "/api/unattended/{id}",
{"method": "DELETE", "path": "/api/unattended/:id",
"summary": "Delete an uploaded answer file."},
{"method": "GET", "path": "/unattended/{id}",
{"method": "GET", "path": "/unattended/:id",
"summary": "Public: serve an answer file with {{HOSTNAME}}/{{IP}}/{{MAC}} substituted from the query string."},
],
},
@@ -1973,9 +1635,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List queue entries (waiting + assigned, with any deployment profile)."},
{"method": "POST", "path": "/api/queue/assign",
"summary": "Assign a target image to queued clients. Body: { target, entry_ids }."},
{"method": "PUT", "path": "/api/queue/{entry_id}/profile",
{"method": "PUT", "path": "/api/queue/:entry_id/profile",
"summary": "Set a queued device's deployment profile. Body: { auto_hostname?, auto_ip?, unattended_file? }."},
{"method": "DELETE", "path": "/api/queue/{entry_id}",
{"method": "DELETE", "path": "/api/queue/:entry_id",
"summary": "Release a queue entry without assigning."},
],
},
@@ -1986,13 +1648,9 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "List per-MAC boot bindings."},
{"method": "POST", "path": "/api/hosts",
"summary": "Pin a MAC to a boot target. Body: { mac, target, label, auto_hostname?, auto_ip?, unattended_file? }."},
{"method": "DELETE", "path": "/api/hosts/{mac}",
{"method": "DELETE", "path": "/api/hosts/:mac",
"summary": "Remove a binding."},
{"method": "GET", "path": "/api/boot-rules",
"summary": "Boot rules + decision-webhook config (v0.7.0)."},
{"method": "PUT", "path": "/api/boot-rules",
"summary": "Replace the whole boot-rules config (rules are ordered)."},
{"method": "POST", "path": "/api/hosts/{mac}/wol",
{"method": "POST", "path": "/api/hosts/:mac/wol",
"summary": "Send a Wake-on-LAN magic packet to a bound MAC (limited + subnet broadcast)."},
{"method": "GET", "path": "/api/boot-log",
"summary": "Ring of recent boot events (timestamp, mac, ip, target)."},
@@ -2821,7 +2479,6 @@ async fn api_network(State(state): State<AppState>) -> Json<serde_json::Value> {
.strip_prefix("http://")
.unwrap_or(&state.public_base_url),
"nic_name": state.nic_name,
"nic_link": state.nic_link,
"subnet_mask": state.subnet_mask,
"gateway": state.gateway,
"dns_server": state.settings.snapshot().dns_server,
@@ -3226,13 +2883,11 @@ mod tests {
auto_ip: Some("10.0.0.7".into()),
unattended_file: Some("ks1".into()),
};
let tokens = openpxe_core::BootTokens::new();
let a = build_unattended_args(
"http://h",
&meta(UnattendedKind::Kickstart),
Some("aa:bb:cc:dd:ee:ff"),
&p,
&tokens,
)
.unwrap();
assert!(a.starts_with("inst.ks=http://h/unattended/ks1?"), "{a}");
@@ -3240,9 +2895,6 @@ mod tests {
assert!(a.contains("ip=10.0.0.7"), "{a}");
// MAC colons are percent-encoded.
assert!(a.contains("mac=aa%3Abb%3Acc%3Add%3Aee%3Aff"), "{a}");
// v0.7.0: a live access token rides in the generated URL.
let tok = a.rsplit("t=").next().unwrap();
assert!(tokens.check(tok, "ks1"), "minted token must be live: {a}");
}
#[test]
@@ -3251,15 +2903,8 @@ mod tests {
auto_hostname: Some("deb1".into()),
..Default::default()
};
let tokens = openpxe_core::BootTokens::new();
let a = build_unattended_args(
"http://h/",
&meta(UnattendedKind::Preseed),
None,
&p,
&tokens,
)
.unwrap();
let a =
build_unattended_args("http://h/", &meta(UnattendedKind::Preseed), None, &p).unwrap();
assert!(
a.starts_with("auto=true priority=critical url=http://h/unattended/ks1"),
"{a}"
@@ -3274,13 +2919,11 @@ mod tests {
auto_ip: Some("10.1.1.5".into()),
unattended_file: Some("ks1".into()),
};
let tokens = openpxe_core::BootTokens::new();
let a = build_unattended_args(
"http://h",
&meta(UnattendedKind::Autoinstall),
Some("aa:bb"),
&p,
&tokens,
)
.unwrap();
assert!(
@@ -3290,12 +2933,10 @@ mod tests {
assert!(a.ends_with('/'), "seed URL must end with '/': {a}");
// The ctx segment round-trips back to the per-host values.
let ctx = a.trim_end_matches('/').rsplit('/').next().unwrap();
let (mac, host, ip, token) = decode_seed_ctx(ctx);
let (mac, host, ip) = decode_seed_ctx(ctx);
assert_eq!(mac.as_deref(), Some("aa:bb"));
assert_eq!(host.as_deref(), Some("u1"));
assert_eq!(ip.as_deref(), Some("10.1.1.5"));
// v0.7.0: the ctx carries a live access token for the file.
assert!(tokens.check(token.as_deref().unwrap(), "ks1"));
}
#[test]
@@ -3304,26 +2945,20 @@ mod tests {
unattended_file: Some("ks1".into()),
..Default::default()
};
let tokens = openpxe_core::BootTokens::new();
assert!(build_unattended_args(
"http://h",
&meta(UnattendedKind::AnswerFile),
None,
&p,
&tokens
)
.is_none());
assert!(
build_unattended_args("http://h", &meta(UnattendedKind::AnswerFile), None, &p)
.is_none()
);
}
#[test]
fn seed_ctx_empty_segment_decodes_to_none() {
let ctx = encode_seed_ctx(None, None, None, "tok");
let (m, h, i, t) = decode_seed_ctx(&ctx);
let ctx = encode_seed_ctx(None, None, None);
let (m, h, i) = decode_seed_ctx(&ctx);
assert!(m.is_none() && h.is_none() && i.is_none());
assert_eq!(t.as_deref(), Some("tok"));
// Garbage decodes safely to all-None.
let (m2, h2, i2, t2) = decode_seed_ctx("!!!not-base64!!!");
assert!(m2.is_none() && h2.is_none() && i2.is_none() && t2.is_none());
let (m2, h2, i2) = decode_seed_ctx("!!!not-base64!!!");
assert!(m2.is_none() && h2.is_none() && i2.is_none());
}
#[test]
+9 -49
View File
@@ -154,28 +154,16 @@ pub fn session_cookie(session: &str) -> String {
fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
// `Cookie: a=b; c=d` parsing — small enough not to drag in a crate.
// Two-step strip (name, then '=') keeps this allocation-free per
// candidate and can't match a longer cookie name sharing the prefix.
let raw = headers.get(header::COOKIE)?.to_str().ok()?;
for part in raw.split(';') {
let part = part.trim();
if let Some(v) = part
.strip_prefix(SESSION_COOKIE)
.and_then(|rest| rest.strip_prefix('='))
{
if let Some(v) = part.strip_prefix(&format!("{SESSION_COOKIE}=")) {
return Some(v.to_string());
}
}
None
}
/// Does this request carry a live operator session? Used by endpoints
/// outside the `/api/*` middleware that still want to honor a logged-in
/// operator (e.g. browser-testing a token-gated answer file, v0.7.0).
pub(crate) fn session_authenticated(state: &AppState, headers: &axum::http::HeaderMap) -> bool {
parse_cookie(headers).is_some_and(|t| state.sessions.touch(&t).is_some())
}
// ── Middleware ────────────────────────────────────────────────────────────
/// Return `true` if `path` is on the allowlist and should bypass the
@@ -258,14 +246,7 @@ pub async fn api_setup(State(state): State<AppState>, Json(body): Json<SetupBody
)
.into_response();
}
// bcrypt hashing is ~100-200 ms of pure CPU (and `bootstrap` also
// persists to disk synchronously) — keep it off the async workers.
let admin = state.admin.clone();
let result =
tokio::task::spawn_blocking(move || admin.bootstrap(&body.username, &body.password))
.await
.unwrap_or_else(|e| Err(openpxe_core::Error::Other(e.into())));
match result {
match state.admin.bootstrap(&body.username, &body.password) {
Ok(pub_) => {
let session = state.sessions.create(&pub_.username);
login_response(StatusCode::CREATED, &pub_, &session)
@@ -290,13 +271,8 @@ pub struct LoginBody {
pub async fn api_login(State(state): State<AppState>, Json(body): Json<LoginBody>) -> Response {
// Brief, deliberately vague — "invalid credentials" rather than
// "no such user" / "wrong password". Same anti-enumeration posture
// as Sonarr/Radarr. The bcrypt verify is ~100-200 ms of pure CPU on
// an unauthenticated endpoint, so it runs on the blocking pool.
let admin = state.admin.clone();
let verdict = tokio::task::spawn_blocking(move || admin.verify(&body.username, &body.password))
.await
.unwrap_or_else(|e| Err(openpxe_core::Error::Other(e.into())));
let pub_ = match verdict {
// as Sonarr/Radarr.
let pub_ = match state.admin.verify(&body.username, &body.password) {
Ok(Some(u)) => u,
Ok(None) => {
return (
@@ -343,12 +319,6 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
// and the logo asset is public, so this leaks nothing sensitive.
let has_custom_logo = state.branding.has_any_web_logo();
let logo_rev = state.branding.logo_rev();
// v0.5.9: ship the non-sensitive SSO descriptor with every /api/me so
// the pre-auth login screen can render the "Sign in with …" button
// reliably. Previously the button keyed off the auth-gated /api/sso,
// which 401s when logged out — the button only survived on a stale
// in-memory config and vanished on any fresh login-page load.
let sso = state.sso.login_info();
if !state.admin.is_configured() {
return (
StatusCode::OK,
@@ -357,7 +327,6 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
"authenticated": false,
"has_custom_logo": has_custom_logo,
"logo_rev": logo_rev,
"sso": sso,
})),
)
.into_response();
@@ -374,7 +343,6 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
"session_user": u,
"has_custom_logo": has_custom_logo,
"logo_rev": logo_rev,
"sso": sso,
})),
)
.into_response(),
@@ -385,7 +353,6 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
"authenticated": false,
"has_custom_logo": has_custom_logo,
"logo_rev": logo_rev,
"sso": sso,
})),
)
.into_response(),
@@ -418,18 +385,11 @@ pub async fn api_update_credentials(
)
.into_response();
}
// Two bcrypt operations (verify current + hash new) plus a sync disk
// persist — run the lot on the blocking pool.
let admin = state.admin.clone();
let result = tokio::task::spawn_blocking(move || {
admin.update_credentials(
&body.current_password,
body.new_username.as_deref(),
body.new_password.as_deref(),
)
})
.await
.unwrap_or_else(|e| Err(openpxe_core::Error::Other(e.into())));
let result = state.admin.update_credentials(
&body.current_password,
body.new_username.as_deref(),
body.new_password.as_deref(),
);
match result {
Ok(pub_) => {
state.sessions.revoke_all();
-192
View File
@@ -1,192 +0,0 @@
//! GRUB menu rendering for the Secure Boot chain (v0.7.0).
//!
//! Secure-Boot-enabled firmware refuses our unsigned iPXE, so those
//! clients are automatically escalated (see `openpxe_dhcp_proxy::
//! escalation`) to the Microsoft-signed Fedora `shim` → signed `grub`
//! chain. GRUB then fetches `grub.cfg` from this server (TFTP `$prefix`
//! resolution, or HTTP when the whole chain came over HTTP Boot) — and
//! this module renders that config from the same boot-entry model that
//! renders `boot.ipxe`.
//!
//! Scope: **Linux kernel entries only.** A signed GRUB will only execute
//! kernels that pass shim verification — i.e. distro-signed kernels —
//! which is exactly what `LinuxKernel` boot entries point at. `sanboot`
//! ISO emulation and `wimboot` are iPXE mechanisms with no signed
//! equivalent; those entries are omitted here, and the menu says so.
//! (Windows deployment under Secure Boot has no legitimate unsigned
//! path — per project policy we never ship test-signed binaries or touch
//! client trust stores.)
//!
//! The kernel/initrd lines use GRUB's `(http,host:port)` device syntax;
//! Fedora's signed netboot GRUB carries the `http`, `tftp` and `efinet`
//! modules built in, so no unsigned module loading is required.
use openpxe_iso_store::{BootKind, IsoMeta};
use std::fmt::Write as _;
/// Render the full `grub.cfg` for the signed-GRUB menu.
///
/// `base_url` is the public HTTP base (`http://10.0.0.5` or
/// `http://10.0.0.5:8080`) — converted to GRUB's `(http,host:port)`
/// device prefix for kernel/initrd fetches.
#[must_use]
pub fn render_grub_menu(isos: &[IsoMeta], base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let dev = grub_http_device(base);
let mut s = String::new();
let _ = writeln!(s, "# OpenPXE — Secure Boot menu (signed shim+GRUB chain)");
// v0.7.1: before showing the limited signed menu, try to hand the
// boot back to full iPXE *in this same boot cycle*. With Secure Boot
// OFF the chainload succeeds and the client gets the complete iPXE
// feature set (sanboot, wimboot, the full menu) despite having been
// escalated here. With Secure Boot ON, shim's verifier refuses the
// unsigned image INLINE — no reboot, no failed cycle — and execution
// falls through to the signed menu below. The all-drivers build is
// used because a MAC only lands here after the firmware-net build
// already failed once.
let _ = writeln!(s, "if [ \"$grub_cpu\" = \"arm64\" ]; then");
let _ = writeln!(s, " set openpxe_ipxe=ipxe-arm64.efi");
let _ = writeln!(s, "else");
let _ = writeln!(s, " set openpxe_ipxe=ipxe.efi");
let _ = writeln!(s, "fi");
let _ = writeln!(s, "if chainloader {dev}/ipxe/$openpxe_ipxe ; then");
let _ = writeln!(s, " boot");
let _ = writeln!(s, "fi");
let _ = writeln!(s);
let _ = writeln!(s, "set timeout=30");
let _ = writeln!(s, "set default=0");
let _ = writeln!(s);
let mut entries = 0usize;
for iso in isos {
for entry in &iso.boot_entries {
let BootKind::LinuxKernel {
kernel_url,
initrd_urls,
args,
} = &entry.kind
else {
continue;
};
// GRUB menu titles: keep quotes out of the label.
let title = entry.title.replace('"', "'");
let cmdline = args.cmdline.replace("${base-url}", base);
let _ = writeln!(s, "menuentry \"{} — {title}\" {{", iso.filename);
let _ = writeln!(s, " linux {dev}/{kernel_url} {cmdline}");
if !initrd_urls.is_empty() {
let _ = write!(s, " initrd");
for u in initrd_urls {
let _ = write!(s, " {dev}/{u}");
}
let _ = writeln!(s);
}
let _ = writeln!(s, "}}");
let _ = writeln!(s);
entries += 1;
}
}
if entries == 0 {
let _ = writeln!(
s,
"menuentry \"No Secure-Boot-bootable images on this server yet\" {{ true }}"
);
let _ = writeln!(s);
}
// Always give the operator a way off this screen.
let _ = writeln!(s, "menuentry \"Boot from local disk\" {{");
let _ = writeln!(s, " exit");
let _ = writeln!(s, "}}");
s
}
/// `http://10.0.0.5:8080` → `(http,10.0.0.5:8080)`. GRUB wants the
/// scheme as the device type and host[:port] as the device address.
fn grub_http_device(base: &str) -> String {
let host = base
.trim_start_matches("http://")
.trim_start_matches("https://");
format!("(http,{host})")
}
#[cfg(test)]
mod tests {
use super::*;
use openpxe_iso_store::{BootEntry, IsoSource, KernelArgs};
fn linux_iso() -> IsoMeta {
IsoMeta {
id: "alp".into(),
filename: "alpine.iso".into(),
size_bytes: 1,
sha256_hex: None,
uploaded_at: time::OffsetDateTime::UNIX_EPOCH,
source: IsoSource::Local,
introspection: openpxe_iso_store::IntrospectionReport::default(),
boot_entries: vec![BootEntry {
id: "alp-linux".into(),
title: "Linux installer".into(),
kind: BootKind::LinuxKernel {
kernel_url: "iso/alp/boot/vmlinuz".into(),
initrd_urls: vec!["iso/alp/boot/initrd".into()],
args: KernelArgs {
cmdline: "quiet repo=${base-url}/iso/alp.iso".into(),
},
},
}],
category: openpxe_iso_store::IsoCategory::default(),
password_hash: None,
}
}
#[test]
fn renders_linux_entries_with_http_device_urls() {
let cfg = render_grub_menu(&[linux_iso()], "http://10.0.0.5:8080/");
assert!(
cfg.contains("menuentry \"alpine.iso — Linux installer\""),
"{cfg}"
);
assert!(
cfg.contains("linux (http,10.0.0.5:8080)/iso/alp/boot/vmlinuz quiet repo=http://10.0.0.5:8080/iso/alp.iso"),
"{cfg}"
);
assert!(
cfg.contains("initrd (http,10.0.0.5:8080)/iso/alp/boot/initrd"),
"{cfg}"
);
assert!(cfg.contains("Boot from local disk"), "{cfg}");
}
#[test]
fn config_tries_ipxe_chainload_before_menu() {
// v0.7.1: SB-off machines recover full iPXE in the same boot;
// SB-on machines fail the chainload inline and reach the menu.
let cfg = render_grub_menu(&[linux_iso()], "http://10.0.0.5:8080");
let chain_pos = cfg
.find("if chainloader (http,10.0.0.5:8080)/ipxe/$openpxe_ipxe ; then")
.expect("chainload attempt missing");
let menu_pos = cfg.find("menuentry").expect("menu missing");
assert!(
chain_pos < menu_pos,
"chainload must precede the menu:\n{cfg}"
);
// Arch-conditional binary selection via GRUB's $grub_cpu.
assert!(cfg.contains("set openpxe_ipxe=ipxe-arm64.efi"), "{cfg}");
assert!(cfg.contains("set openpxe_ipxe=ipxe.efi"), "{cfg}");
}
#[test]
fn sanboot_and_wimboot_entries_are_omitted() {
let mut iso = linux_iso();
iso.boot_entries = vec![BootEntry {
id: "win".into(),
title: "Windows".into(),
kind: BootKind::SanBootIso {
iso_url: "iso/win.iso".into(),
},
}];
let cfg = render_grub_menu(&[iso], "http://10.0.0.5");
assert!(!cfg.contains("Windows"), "{cfg}");
assert!(cfg.contains("No Secure-Boot-bootable images"), "{cfg}");
}
}
+135
View File
@@ -0,0 +1,135 @@
//! Minimal read-only ISO9660 lookup. Given an uploaded ISO file and an
//! in-ISO path (e.g. `/casper/vmlinuz`), locate the file and return a
//! `(start_byte, length_bytes)` pair so the HTTP handler can stream just
//! that range from the on-disk ISO without full extraction.
//!
//! We only implement what we need: the Primary Volume Descriptor and Rock
//! Ridge / Joliet extensions are ignored. Paths are matched case-insensitive
//! against plain ISO9660 filenames (uppercase, `;1` version suffix stripped).
//! This is sufficient for the kernel/initrd and wimboot files we serve;
//! if a requested path isn't found, the handler returns 404 and the user
//! can still download the whole ISO via `/iso/<id>.iso`.
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
const SECTOR: u64 = 2048;
#[derive(Debug, Clone)]
pub struct FileLocation {
pub offset: u64,
pub length: u64,
}
/// Look up `in_iso_path` (leading slash optional, case-insensitive) in the
/// ISO at `iso_path`. Returns None on any parsing or IO failure.
pub fn lookup(iso_path: &Path, in_iso_path: &str) -> Option<FileLocation> {
let mut f = std::fs::File::open(iso_path).ok()?;
let root = read_root_directory(&mut f)?;
let components: Vec<&str> = in_iso_path
.trim_start_matches('/')
.split('/')
.filter(|c| !c.is_empty())
.collect();
if components.is_empty() {
return None;
}
walk(&mut f, root.offset, root.length, &components)
}
fn read_root_directory(f: &mut std::fs::File) -> Option<FileLocation> {
// Primary Volume Descriptor at LBA 16.
let mut pvd = [0u8; 2048];
f.seek(SeekFrom::Start(16 * SECTOR)).ok()?;
f.read_exact(&mut pvd).ok()?;
if pvd[0] != 0x01 || &pvd[1..6] != b"CD001" {
return None;
}
// Root directory record is at offset 156, length 34.
let rec = &pvd[156..156 + 34];
let (offset, length) = parse_dir_record_ext(rec)?;
Some(FileLocation {
offset: offset * SECTOR,
length,
})
}
/// Walk components down the directory tree starting at `dir_offset`.
fn walk(
f: &mut std::fs::File,
dir_offset: u64,
dir_len: u64,
components: &[&str],
) -> Option<FileLocation> {
let mut dir = vec![0u8; dir_len as usize];
f.seek(SeekFrom::Start(dir_offset)).ok()?;
f.read_exact(&mut dir).ok()?;
let target = components[0];
let rest = &components[1..];
let mut i = 0;
while i < dir.len() {
let len = dir[i] as usize;
if len == 0 {
// Padding to sector boundary.
let next = (i / SECTOR as usize + 1) * SECTOR as usize;
if next <= i {
break;
}
i = next;
continue;
}
if i + len > dir.len() {
break;
}
let rec = &dir[i..i + len];
let name = dir_record_name(rec);
let is_dir = (rec.get(25).copied().unwrap_or(0) & 0x02) != 0;
// Skip "." (0x00) and ".." (0x01) pseudo-entries.
let is_pseudo = matches!(rec.get(32).copied(), Some(1))
&& rec.get(33).copied() == Some(0x00)
|| matches!(rec.get(32).copied(), Some(1)) && rec.get(33).copied() == Some(0x01);
if !is_pseudo && name.eq_ignore_ascii_case(target) {
let (child_off, child_len) = parse_dir_record_ext(rec)?;
if rest.is_empty() && !is_dir {
return Some(FileLocation {
offset: child_off * SECTOR,
length: child_len,
});
} else if !rest.is_empty() && is_dir {
return walk(f, child_off * SECTOR, child_len, rest);
}
}
i += len;
}
None
}
/// Extract (extent LBA, data length in bytes) from a directory record.
/// Layout per ISO9660: bytes 2..10 extent LBA (LE+BE duplicate), 10..18
/// data length (LE+BE duplicate). We trust the little-endian copy.
fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> {
if rec.len() < 34 {
return None;
}
let lba = u32::from_le_bytes(rec[2..6].try_into().ok()?) as u64;
let len = u32::from_le_bytes(rec[10..14].try_into().ok()?) as u64;
Some((lba, len))
}
/// Extract the identifier from a directory record, stripping ISO9660's
/// `;1` version suffix.
fn dir_record_name(rec: &[u8]) -> String {
let name_len = *rec.get(32).unwrap_or(&0) as usize;
if name_len == 0 || rec.len() < 33 + name_len {
return String::new();
}
let raw = &rec[33..33 + name_len];
let s = String::from_utf8_lossy(raw).to_string();
// Strip `;N` version suffix.
if let Some(i) = s.rfind(';') {
s[..i].to_string()
} else {
s
}
}
+3 -4
View File
@@ -9,16 +9,15 @@
//! and Linux kernel/initrd, without having to
//! re-extract on every request)
//!
//! The `<id>/<path>` handler uses the read-only ISO9660 walker from
//! `openpxe_iso_store::iso_fs` — seeking into the image wherever it
//! lives (local disk, NFS, SFTP), so we never keep extracted copies.
//! The `<id>/<path>` handler uses a read-only ISO9660 shim (see `iso_fs`)
//! that lseeks into the ISO on disk — so we never keep extracted copies.
#![forbid(unsafe_code)]
pub mod app;
pub mod auth;
pub mod error;
pub mod grub_script;
pub mod ipxe_script;
pub mod iso_fs;
pub mod log_stream;
pub mod notify;
pub mod saml_routes;
+1 -5
View File
@@ -105,11 +105,7 @@ async fn send_email(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(),
.trim()
.parse()
.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())
.map_err(|e| format!("could not build email: {e}"))?;
+2 -24
View File
@@ -2,8 +2,8 @@ use crate::auth::SessionStore;
use crate::saml_routes::SamlRuntime;
use crate::uploads::UploadSessions;
use openpxe_core::{
AdminStore, BootLog, BootRulesStore, BootTokens, BrandingStore, ClientRegistry,
DeploymentQueue, HostBindings, LogBus, Metrics, NotifyStore, SettingsStore, SsoStore,
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
Metrics, NotifyStore, SettingsStore, SsoStore,
};
use openpxe_iso_store::{
IsoStore, NfsShareManager, SftpShareManager, SmbManager, SmbShareManager, UnattendedStore,
@@ -11,10 +11,6 @@ use openpxe_iso_store::{
use std::sync::Arc;
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)]
pub struct AppState {
pub iso_store: IsoStore,
@@ -29,24 +25,10 @@ pub struct AppState {
/// every `/boot/<entry>.ipxe` chain that goes on to serve a script
/// (i.e. an image actually starting to install on a machine).
pub boot_log: BootLog,
/// v0.7.0: ordered label-based boot rules (MAC prefix / arch →
/// target) plus the optional boot-decision webhook. Consulted by the
/// top-level boot script after exact host bindings, before the menu.
pub boot_rules: BootRulesStore,
/// v0.7.0: short-lived access tokens for unattended answer files.
/// Minted into every generated answer-file URL; the serving endpoint
/// requires one (or an operator session) once an admin exists.
pub boot_tokens: BootTokens,
/// Operator-controlled UI overrides (custom logo). When the
/// operator hasn't uploaded anything, the WebUI serves the bundled
/// rainbow-horizon mark.
pub branding: BrandingStore,
/// v0.6.2: cache of the composited PXE boot-menu background PNG,
/// keyed on the branding logo revision. Composing costs ~50-200 ms
/// of image decode/encode and **every** booting client fetches it
/// for `console --picture` — caching makes that one compose per
/// logo change instead of one per boot.
pub pxe_bg_cache: PxeBgCache,
/// Forms-auth admin record + first-run bootstrap state. When
/// `admin.is_configured() == false`, the auth middleware passes
/// every request through and `/api/me` reports `setup_required`.
@@ -116,10 +98,6 @@ pub struct AppState {
/// `enp1s0`). Surfaced read-only on the Network tab. Empty if the
/// interface couldn't be identified.
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.
pub subnet_mask: String,
/// Default gateway IPv4 address.
+21 -179
View File
@@ -18,16 +18,23 @@ use openpxe_iso_store::{IsoStore, NfsShareManager, SftpShareManager, SmbShareMan
use tempfile::tempdir;
use tower::ServiceExt;
/// Build a tiny Alpine-shaped ISO9660 image: volume label "ALPINE-TEST"
/// plus the real `/boot/vmlinuz-lts` + `/boot/initramfs-lts` tree.
/// v0.7.4's probe-based introspection verifies those paths exist before
/// emitting a kernel boot entry — a label-only blob no longer counts.
/// Build a tiny valid ISO9660 blob with volume label "ALPINE-TEST" so
/// introspection identifies it as Alpine.
fn fake_alpine_iso() -> Vec<u8> {
openpxe_iso_store::iso_fs::testiso::TestIsoBuilder::new("ALPINE-TEST")
.el_torito(true)
.file("/boot/vmlinuz-lts", b"fake-kernel-bytes")
.file("/boot/initramfs-lts", b"fake-initramfs-bytes")
.build()
let mut buf = vec![0u8; 32 * 2048];
let off = 16 * 2048;
buf[off] = 0x01;
buf[off + 1..off + 6].copy_from_slice(b"CD001");
buf[off + 6] = 0x01;
let label = b"ALPINE-TEST".to_vec();
let mut padded = label.clone();
padded.resize(32, b' ');
buf[off + 40..off + 40 + 32].copy_from_slice(&padded);
let term = 17 * 2048;
buf[term] = 0xFF;
buf[term + 1..term + 6].copy_from_slice(b"CD001");
buf[term + 6] = 0x01;
buf
}
fn multipart_iso_body(filename: &str, bytes: &[u8]) -> (String, Vec<u8>) {
@@ -108,10 +115,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
settings,
hosts,
boot_log,
boot_rules: openpxe_core::BootRulesStore::load_or_default(dir.path()),
boot_tokens: openpxe_core::BootTokens::new(),
branding,
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
admin,
sessions,
sso,
@@ -128,7 +132,6 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
started_at: time::OffsetDateTime::now_utc(),
public_base_url: "http://127.0.0.1".into(),
nic_name: "lo".into(),
nic_link: String::new(),
subnet_mask: "255.0.0.0".into(),
gateway: "127.0.0.1".into(),
};
@@ -1241,10 +1244,9 @@ async fn chunked_upload_writes_progressively_and_finishes_iso() {
.method("POST")
.uri("/api/uploads")
.header("content-type", "application/json")
.body(Body::from(format!(
r#"{{"filename":"chunked-alpine.iso","size_bytes":{}}}"#,
iso.len()
)))
.body(Body::from(
r#"{"filename":"chunked-alpine.iso","size_bytes":65536}"#,
))
.unwrap(),
)
.await
@@ -1486,10 +1488,9 @@ async fn api_docs_lists_known_endpoints() {
}
for needle in [
"/api/isos",
// v0.6.3: docs use axum 0.8's `{param}` capture syntax.
"/api/isos/{id}/category",
"/api/isos/:id/category",
"/api/storage/disk",
"/api/branding/logo/{slot}",
"/api/branding/logo/:slot",
"/api/unattended",
"/api/boot-log",
"/metrics",
@@ -2607,162 +2608,3 @@ async fn acs_garbage_is_rejected_without_500() {
assert!(location(&resp).contains("sso_error"));
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)
);
}
+37 -57
View File
@@ -6,40 +6,43 @@
//! missing, that architecture simply won't have PXE support — we log at
//! startup and serve what we have.
//!
//! Filename convention (matches `ClientArch::ipxe_bootfile_mode`):
//!
//! DriverMode::Firmware (default — reuse the firmware UNDI/SNP NIC stack):
//! Filename convention (matches `ClientArch::ipxe_bootfile`):
//! - `undionly.kpxe` — Legacy x86 BIOS
//! - `snponly-i386.efi` — IA32 UEFI
//! - `snponly.efi` — x86_64 UEFI
//! - `snponly-arm32.efi` — ARM32 UEFI
//! - `snponly-arm64.efi` — ARM64 UEFI
//!
//! 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
//!
//! - `ipxe.efi` (fallback) — UEFI with bundled drivers, if snponly fails on a NIC
//! - `wimboot` — Windows boot shim (fetched separately for WIM chains)
#![forbid(unsafe_code)]
use openpxe_core::{ClientArch, DriverMode};
use openpxe_core::ClientArch;
use rust_embed::Embed;
#[derive(Embed)]
#[folder = "../../assets/ipxe/"]
#[include = "*.kpxe"]
#[include = "*.efi"]
#[include = "*.pxe"]
#[include = "wimboot"]
pub struct IpxeAssets;
/// Return a named embedded asset (e.g. `snponly.efi`, `wimboot`) as a
/// `Cow` over the embedded bytes. In release builds the data is borrowed
/// straight from the binary's rodata — **zero copy** — which matters
/// because the TFTP and HTTP serving paths hit this for every boot
/// (`ipxe.efi` is ~1 MiB). Debug builds read from disk and return Owned.
/// Return the embedded iPXE binary for `arch`, or `None` if we didn't bundle
/// one for that architecture.
#[must_use]
pub fn bootfile_bytes(arch: ClientArch) -> Option<Vec<u8>> {
let name = arch.ipxe_bootfile()?;
IpxeAssets::get(name).map(|f| f.data.into_owned())
}
/// Return a named asset directly (e.g. `wimboot`, or a fallback `ipxe.efi`).
#[must_use]
pub fn asset_bytes(name: &str) -> Option<Vec<u8>> {
IpxeAssets::get(name).map(|f| f.data.into_owned())
}
/// Same as [`asset_bytes`] but returns the embedded slice directly,
/// avoiding the heap copy when the caller only needs to read the
/// payload. Falls back to None for unknown names.
#[must_use]
pub fn asset_slice(name: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
IpxeAssets::get(name).map(|f| f.data)
@@ -53,48 +56,25 @@ pub fn list_assets() -> Vec<String> {
.collect()
}
/// 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.
/// Log at startup which iPXE binaries are present and which are missing.
pub fn log_availability() {
let have: std::collections::HashSet<String> = list_assets().into_iter().collect();
let arches = [
ClientArch::LegacyX86,
ClientArch::Ia32Uefi,
ClientArch::X64Uefi,
// ARM32 UEFI deferred — no upstream binary published in either mode.
ClientArch::Arm64Uefi,
let needed = [
(ClientArch::LegacyX86, "undionly.kpxe"),
(ClientArch::Ia32Uefi, "snponly-i386.efi"),
(ClientArch::X64Uefi, "snponly.efi"),
// ARM32 UEFI deferred — no upstream snponly binary published.
(ClientArch::Arm64Uefi, "snponly-arm64.efi"),
];
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) {
tracing::info!(
target: "openpxe::ipxe",
"bundled iPXE for {} [{mode:?}]: {name}", arch.as_str()
);
} else if mode == DriverMode::Firmware {
tracing::warn!(
target: "openpxe::ipxe",
"MISSING iPXE binary for {} [{mode:?}]: {name} — clients of this arch will not PXE boot",
arch.as_str()
);
} else if mode == DriverMode::Builtin {
tracing::info!(
target: "openpxe::ipxe",
"no built-in-driver fallback for {} [{mode:?}]: {name} — auto NIC driver escalation unavailable for this arch",
arch.as_str()
);
} else {
tracing::info!(
target: "openpxe::ipxe",
"no signed shim chain for {} [{mode:?}]: {name} — Secure Boot clients of this arch can't be served",
arch.as_str()
);
}
for (arch, name) in needed {
if have.contains(name) {
tracing::info!(target: "openpxe::ipxe", "bundled iPXE for {}: {}", arch.as_str(), name);
} else {
tracing::warn!(
target: "openpxe::ipxe",
"MISSING iPXE binary for {}: {} — clients of this arch will not PXE boot",
arch.as_str(), name
);
}
}
}
-8
View File
@@ -49,11 +49,3 @@ futures = { workspace = true }
[dev-dependencies]
tempfile = "3.12"
[features]
# v0.7.4: exposes the in-memory ISO9660 test-image builder
# (`iso_fs::testiso`) to other crates' integration tests, so http-api's
# full-flow tests can synthesize ISOs with real directory trees — the
# probe-based introspection no longer classifies label-only blobs.
# Never enabled in production builds.
test-image = []
+3 -5
View File
@@ -25,11 +25,9 @@ pub enum BootKind {
wimboot_url: String,
files: Vec<(String, String)>,
},
/// SAN-boot the raw ISO as an emulated CD (iPXE `sanboot`). The emulated
/// CD is backed by on-demand HTTP range reads, so ISO size is *not* a
/// 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).
/// Last-resort: SAN-boot the ISO as an emulated CD. Only works for small
/// ISOs (<~1 GiB) and older distros. Kept for completeness, not the
/// default.
SanBootIso { iso_url: String },
}
+125 -570
View File
@@ -1,36 +1,19 @@
//! ISO introspection — identify the distro family and locate kernel/initrd.
//!
//! v0.7.4 rewrite: detection is **probe-based**. Instead of grepping raw
//! sectors for filename strings (which false-positived — any Linux ISO
//! shipping GRUB/syslinux chainload modules contains the literal
//! "bootmgr", so gparted-live classified as Windows), we walk the
//! ISO9660 directory tree via [`crate::iso_fs`] and check whether the
//! well-known boot files actually exist. The same probes run over local
//! files and remote NFS/SFTP shares — remote ISOs finally classify
//! instead of registering as `Unknown`.
//! We avoid a full ISO9660/Joliet/Rock-Ridge parser by reading a small number
//! of well-known files via `isoinfo` (from cdrtools/genisoimage) when it's on
//! the path. As a pure-Rust fallback we do a crude scan: read the volume
//! descriptor at offset 0x8000 to grab the volume label, and grep for known
//! filenames by scanning raw sectors — good enough to tell Debian from RHEL
//! most of the time, without shelling out.
//!
//! Layered, first-decisive-answer-wins:
//! 1. PVD volume label → family hint.
//! 2. El Torito boot-catalog presence (the "bootable at all" signal).
//! 3. `/sources/boot.wim` directory probe → Windows install media.
//! 4. Linux probe table → verified kernel+initrd paths. A probe match
//! both classifies the family and (for the families whose boot
//! arguments we render) yields kernel paths that are *known to
//! exist* — no more guessed paths that 404 at boot.
//! 5. Bulk byte scan for UDF Windows markers — local images only
//! (modern Windows ISOs hide their tree from ISO9660; remote scans
//! skip this so a share rescan doesn't stream 16 MiB per ISO).
//! 6. Filename tokens — the last-resort hint, and the only signal
//! available for SMB shares (smbclient cannot seek).
//!
//! The returned `IntrospectionReport` is what `BootEntry`s get generated
//! from.
//! The returned `IntrospectionReport` is what `BootEntry`s get generated from.
use crate::iso_fs::{self, CachingReadAt, FileReadAt, IsoReadAt, SECTOR};
use serde::{Deserialize, Serialize};
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DistroFamily {
DebianUbuntu,
@@ -39,283 +22,123 @@ pub enum DistroFamily {
Arch,
Alpine,
WindowsPe,
#[default]
Unknown,
}
/// 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 without the operator having to delete and re-upload.
///
/// rev 1 (v0.5.9): added El Torito boot-catalog detection + broadened
/// Windows (UDF/UTF-16) detection.
/// rev 2 (v0.7.4): probe-based detection. Fixes Linux live ISOs that
/// classified as Windows via the raw "bootmgr" byte grep, verifies
/// kernel/initrd paths exist before emitting them, and adds the Debian
/// live / netinst / CoreOS shapes. Remote (NFS/SFTP) introspection
/// caches key off this rev too, so the cache self-invalidates.
pub const INTROSPECT_REV: u32 = 2;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntrospectionReport {
pub family: DistroFamily,
pub volume_label: Option<String>,
/// Kernel path inside the ISO (e.g. `/casper/vmlinuz`). v0.7.4: only
/// set when the path was verified to exist *and* the family's boot
/// arguments are known-good for direct kernel boot; families we can
/// only classify (Debian live, CoreOS live) leave it `None` so the
/// entry generator falls back to sanboot instead of a broken boot.
/// Kernel path inside the ISO (e.g. `/casper/vmlinuz`, `/isolinux/vmlinuz`).
pub kernel_path: Option<String>,
/// Initrd path(s) inside the ISO. May be multiple for multi-initrd setups.
pub initrd_paths: Vec<String>,
/// True if `sources/boot.wim` present — Windows install media.
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.
/// `0` means "never introspected" (pre-v0.5.9 metadata, or a remote
/// ISO whose probe hasn't run / can't run) — the entry generator
/// treats those optimistically (sanboot) and the UI labels them.
#[serde(default)]
pub introspect_rev: u32,
}
/// One row of the Linux detection table.
///
/// `emit_kernel` distinguishes "we can boot this directly" from "we can
/// only classify it". Families marked `false` have boot protocols our
/// cmdline renderer doesn't speak yet (Debian-live `boot=live fetch=`,
/// d-i netinst, CoreOS `coreos.live.rootfs_url=`) — for those the probe
/// sets the family for the UI/menu but leaves `kernel_path` unset so the
/// ISO keeps its (working) sanboot entry instead of gaining a broken
/// kernel one. Strictly fewer broken boots than guessing.
struct LinuxProbe {
family: DistroFamily,
kernel: &'static str,
initrd_candidates: &'static [&'static str],
emit_kernel: bool,
}
const LINUX_PROBES: &[LinuxProbe] = &[
// Ubuntu and friends (casper) — the classic direct-boot shape.
LinuxProbe {
family: DistroFamily::DebianUbuntu,
kernel: "/casper/vmlinuz",
initrd_candidates: &["/casper/initrd", "/casper/initrd.lz", "/casper/initrd.gz"],
emit_kernel: true,
},
// Debian-live derivatives: gparted-live, Clonezilla, Kali live, tails.
// Classification only — live-boot needs `boot=live fetch=<squashfs>`
// which we don't render yet; sanboot of these images works today.
LinuxProbe {
family: DistroFamily::DebianUbuntu,
kernel: "/live/vmlinuz",
initrd_candidates: &["/live/initrd.img", "/live/initrd"],
emit_kernel: false,
},
// Debian installer (netinst/DVD). Classification only for the same
// reason — d-i sanboots fine.
LinuxProbe {
family: DistroFamily::DebianUbuntu,
kernel: "/install.amd/vmlinuz",
initrd_candidates: &["/install.amd/initrd.gz"],
emit_kernel: false,
},
// Anaconda family: RHEL, CentOS, Alma, Rocky, Fedora — and their
// many derivatives (Cisco ISE, Nagios appliances, …). The CoreOS
// variant of this shape is special-cased after the table.
LinuxProbe {
family: DistroFamily::RhelFedora,
kernel: "/images/pxeboot/vmlinuz",
initrd_candidates: &["/images/pxeboot/initrd.img"],
emit_kernel: true,
},
LinuxProbe {
family: DistroFamily::OpenSuse,
kernel: "/boot/x86_64/loader/linux",
initrd_candidates: &["/boot/x86_64/loader/initrd"],
emit_kernel: true,
},
LinuxProbe {
family: DistroFamily::Arch,
kernel: "/arch/boot/x86_64/vmlinuz-linux",
initrd_candidates: &["/arch/boot/x86_64/initramfs-linux.img"],
emit_kernel: true,
},
LinuxProbe {
family: DistroFamily::Alpine,
kernel: "/boot/vmlinuz-lts",
initrd_candidates: &["/boot/initramfs-lts"],
emit_kernel: true,
},
];
/// CoreOS-style live images (RHCOS, FCOS, OpenShift agent ISOs) carry
/// the anaconda pxeboot layout *plus* a rootfs image. Direct kernel boot
/// of those requires `coreos.live.rootfs_url=` (and for agent ISOs, the
/// ignition config embedded in the ISO device) — neither of which a
/// plain `inst.repo=` cmdline provides. Their sanboot path works, so
/// they classify as RHEL-family but keep the sanboot entry.
const COREOS_ROOTFS: &str = "/images/pxeboot/rootfs.img";
/// 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.
/// 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.
pub fn introspect(path: &Path) -> IntrospectionReport {
let filename = path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let Ok(f) = std::fs::File::open(path) else {
tracing::warn!(target: "openpxe::iso", "cannot open ISO for introspection: {}", path.display());
return IntrospectionReport {
introspect_rev: INTROSPECT_REV,
..Default::default()
};
};
let len = f.metadata().map_or(0, |m| m.len());
// `FileReadAt` completes every read inline (no real awaits), so this
// light-weight block_on never parks; callers already run us on the
// blocking pool.
futures::executor::block_on(introspect_reader(
&mut FileReadAt::new(f),
len,
&filename,
true,
))
}
/// The detection core, generic over any random-access source. `total_len`
/// is the image size (every caller knows it — file metadata locally, the
/// share listing remotely) and bounds the bulk scan, since `IsoReadAt`
/// reads are exact-or-error. `filename` feeds the last-resort token
/// heuristics; `allow_bulk_scan` gates the 16 MiB UDF-Windows byte scan
/// (local files only — remote shares would stream that much per ISO per
/// rescan).
pub async fn introspect_reader<R: IsoReadAt + Send>(
r: &mut R,
total_len: u64,
filename: &str,
allow_bulk_scan: bool,
) -> IntrospectionReport {
let mut report = IntrospectionReport {
introspect_rev: INTROSPECT_REV,
..Default::default()
family: DistroFamily::Unknown,
volume_label: None,
kernel_path: None,
initrd_paths: Vec::new(),
has_boot_wim: false,
};
// ISO9660 Primary Volume Descriptor at LBA 16. Bytes 40..72 are the
// volume identifier (space-padded).
if let Ok(pvd) = r.read_at(16 * SECTOR, 2048).await {
let Ok(mut f) = std::fs::File::open(path) else {
tracing::warn!(target: "openpxe::iso", "cannot open ISO for introspection: {}", path.display());
return report;
};
// ISO9660 Primary Volume Descriptor at LBA 16 (offset 0x8000), 2048 bytes.
// Bytes 40..72 are the Volume Identifier (space-padded, d-characters).
let mut pvd = [0u8; 2048];
if f.seek(SeekFrom::Start(0x8000)).is_ok() && f.read_exact(&mut pvd).is_ok() {
// Byte 0 must be 0x01 (primary descriptor), bytes 1..6 = "CD001".
if pvd[0] == 0x01 && &pvd[1..6] == b"CD001" {
let label = String::from_utf8_lossy(&pvd[40..72]).trim().to_string();
let label_raw = &pvd[40..72];
let label = String::from_utf8_lossy(label_raw).trim().to_string();
if !label.is_empty() {
report.volume_label = Some(label.clone());
report.family = family_from_label(&label);
report.volume_label = Some(label);
}
}
}
report.el_torito = detect_el_torito(r).await;
// Cheap content scan: read the first ~64 MiB, look for signature filenames.
// This is enough to identify `sources/boot.wim` (Windows) and common
// kernel/initrd paths for the major Linux distros.
let _ = f.seek(SeekFrom::Start(0));
let scan_bytes = 64 * 1024 * 1024;
let mut buf = vec![0u8; 1024 * 1024];
let mut read_total = 0usize;
let mut haystack = Vec::with_capacity(scan_bytes.min(32 * 1024 * 1024));
while read_total < scan_bytes {
let n = f.read(&mut buf).unwrap_or(0);
if n == 0 {
break;
}
haystack.extend_from_slice(&buf[..n]);
read_total += n;
}
// Directory-tree probes. The caching wrapper collapses the repeated
// root/subdirectory reads the probe table would otherwise issue —
// over NFS/SFTP that's the difference between ~6 and ~60 round-trips.
// `sources/boot.wim` is the definitive Windows-install-media marker
// when the ISO exposes ASCII (ISO9660/Joliet) names. `contains_ascii`
// is case-insensitive, so one form covers BOOT.WIM / boot.wim and the
// backslash variant.
if contains_ascii(&haystack, b"sources/boot.wim")
|| contains_ascii(&haystack, b"sources\\boot.wim")
{
let mut cr = CachingReadAt::new(r);
if iso_fs::exists(&mut cr, "/sources/boot.wim").await {
report.has_boot_wim = true;
report.family = DistroFamily::WindowsPe;
} else {
for probe in LINUX_PROBES {
if !iso_fs::exists(&mut cr, probe.kernel).await {
continue;
}
let mut initrd = None;
for cand in probe.initrd_candidates {
if iso_fs::exists(&mut cr, cand).await {
initrd = Some((*cand).to_string());
break;
}
}
let Some(initrd) = initrd else { continue };
// Content beats label: a rebadged derivative (volume
// label "ISE-3.2") with the anaconda layout is
// RHEL-family no matter what the label says.
report.family = probe.family;
let coreos = probe.family == DistroFamily::RhelFedora
&& iso_fs::exists(&mut cr, COREOS_ROOTFS).await;
if probe.emit_kernel && !coreos {
report.kernel_path = Some(probe.kernel.to_string());
report.initrd_paths = vec![initrd];
}
break;
}
}
report.has_boot_wim = true;
report.family = DistroFamily::WindowsPe;
}
// Modern Windows 10/11 ISOs are UDF — their tree is invisible to the
// ISO9660 walk and the volume label is a cryptic Microsoft string.
// Scan the first 16 MiB for well-known markers, ASCII and UTF-16LE.
// Runs after the Linux probes so a Linux ISO that *contains* the
// string "bootmgr" (GRUB/syslinux chainload modules do) has already
// classified and never reaches this — that ordering is the v0.7.4
// gparted-misdetection fix.
if report.family == DistroFamily::Unknown && allow_bulk_scan {
if let Some(win) = bulk_windows_scan(r, total_len).await {
report.family = DistroFamily::WindowsPe;
report.has_boot_wim = win;
}
}
// Last resort: filename tokens. The only signal for SMB-sourced ISOs
// and renamed/UDF images that defeated everything above.
// 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 {
report.family = family_from_filename(filename);
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;
}
}
// Best-effort kernel/initrd path guess from family. These paths are what
// distro ISOs conventionally ship at — we don't verify extraction here;
// that happens in the store after introspection.
let (k, i) = guess_kernel_initrd(report.family);
report.kernel_path = k.map(str::to_string);
report.initrd_paths = i.iter().map(std::string::ToString::to_string).collect();
report
}
/// Provisional report for a remote ISO that hasn't been (or can't be)
/// content-probed yet: family from the filename, `introspect_rev` left
/// at 0 so the entry generator keeps the optimistic sanboot entry and
/// the UI shows it as awaiting introspection. Used by all three share
/// managers at registration; NFS/SFTP upgrade it in the background.
#[must_use]
pub fn provisional_report(filename: &str) -> IntrospectionReport {
IntrospectionReport {
family: family_from_filename(filename),
..Default::default()
}
}
fn family_from_label(label: &str) -> DistroFamily {
let l = label.to_ascii_lowercase();
if l.contains("ubuntu")
|| l.contains("debian")
|| l.contains("mint")
|| l.contains("kali")
|| l.contains("gparted")
|| l.contains("clonezilla")
{
if l.contains("ubuntu") || l.contains("debian") || l.contains("mint") {
DistroFamily::DebianUbuntu
} else if l.contains("rhel")
|| l.contains("centos")
|| l.contains("fedora")
|| l.contains("rocky")
|| l.contains("alma")
|| l.contains("rhcos")
|| l.contains("coreos")
|| l.contains("openshift")
|| l.contains("okd")
{
DistroFamily::RhelFedora
} else if l.contains("suse") || l.contains("opensuse") {
@@ -331,91 +154,23 @@ fn family_from_label(label: &str) -> DistroFamily {
}
}
/// Filename token heuristic — `AlmaLinux-9.5-x86_64-dvd.iso` says what
/// it is even when we can't read a byte of it. Tokens are the filename
/// split on every non-alphanumeric character, so "almalinux", "rhel",
/// "win11" match without "search" tripping the "arch" token.
pub fn family_from_filename(filename: &str) -> DistroFamily {
if filename_looks_windows(filename) {
return DistroFamily::WindowsPe;
}
let lower = filename.to_ascii_lowercase();
let tokens: Vec<&str> = lower
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|t| !t.is_empty())
.collect();
let has = |t: &str| tokens.contains(&t);
if has("ubuntu")
|| has("debian")
|| has("mint")
|| has("kali")
|| has("gparted")
|| has("clonezilla")
|| has("tails")
{
DistroFamily::DebianUbuntu
} else if has("rhel")
|| has("centos")
|| has("almalinux")
|| has("alma")
|| has("rocky")
|| has("rockylinux")
|| has("fedora")
|| has("rhcos")
|| has("coreos")
|| has("openshift")
|| has("okd")
{
DistroFamily::RhelFedora
} else if has("opensuse") || has("suse") || has("sles") {
DistroFamily::OpenSuse
} else if has("arch") || has("archlinux") || has("manjaro") {
DistroFamily::Arch
} else if has("alpine") {
DistroFamily::Alpine
} else {
DistroFamily::Unknown
}
}
/// Scan the first 16 MiB (or the whole image when smaller) for Windows
/// markers. Returns `Some(has_boot_wim)` on a hit, `None` when nothing
/// Windows-shaped is found.
async fn bulk_windows_scan<R: IsoReadAt + Send>(r: &mut R, total_len: u64) -> Option<bool> {
const SCAN_BYTES: u64 = 16 * 1024 * 1024;
const CHUNK: u64 = 1024 * 1024;
let budget = SCAN_BYTES.min(total_len);
let mut haystack = Vec::with_capacity(usize::try_from(budget).unwrap_or(0));
let mut offset = 0u64;
while offset < budget {
// Reads are exact-or-error, so clamp the final chunk to what the
// image actually has — netboot.xyz is 2.3 MB, not 16.
let want = u32::try_from(CHUNK.min(budget - offset)).unwrap_or(u32::MAX);
let Ok(chunk) = r.read_at(offset, want).await else {
break; // read error: scan what we have
};
offset += chunk.len() as u64;
haystack.extend_from_slice(&chunk);
}
if haystack.is_empty() {
return None;
}
let boot_wim = contains_ascii(&haystack, b"sources/boot.wim")
|| contains_ascii(&haystack, b"sources\\boot.wim")
|| contains_utf16le_ci(&haystack, "boot.wim");
if boot_wim {
return Some(true);
}
let ascii_markers: [&[u8]; 3] = [b"bootmgr", b"sources/install.wim", b"sources/install.esd"];
let utf16_markers = ["bootmgr", "install.wim", "microsoft"];
let hit = ascii_markers.iter().any(|m| contains_ascii(&haystack, m))
|| utf16_markers
.iter()
.any(|m| contains_utf16le_ci(&haystack, m));
if hit {
Some(false)
} else {
None
fn guess_kernel_initrd(family: DistroFamily) -> (Option<&'static str>, Vec<&'static str>) {
match family {
DistroFamily::DebianUbuntu => (Some("/casper/vmlinuz"), vec!["/casper/initrd"]),
DistroFamily::RhelFedora => (
Some("/images/pxeboot/vmlinuz"),
vec!["/images/pxeboot/initrd.img"],
),
DistroFamily::OpenSuse => (
Some("/boot/x86_64/loader/linux"),
vec!["/boot/x86_64/loader/initrd"],
),
DistroFamily::Arch => (
Some("/arch/boot/x86_64/vmlinuz-linux"),
vec!["/arch/boot/x86_64/initramfs-linux.img"],
),
DistroFamily::Alpine => (Some("/boot/vmlinuz-lts"), vec!["/boot/initramfs-lts"]),
DistroFamily::WindowsPe | DistroFamily::Unknown => (None, Vec::new()),
}
}
@@ -448,10 +203,14 @@ fn contains_utf16le_ci(haystack: &[u8], ascii: &str) -> bool {
/// Filename heuristic: a stock Windows ISO almost always carries an obvious
/// token in its name (e.g. `..._windows_11_...`, `Win10`, `winserver`).
/// v0.7.4: takes the bare filename instead of a `Path` so the same check
/// runs against remote share listings.
fn filename_looks_windows(filename: &str) -> bool {
let name = filename.to_ascii_lowercase();
/// 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",
@@ -463,52 +222,9 @@ fn filename_looks_windows(filename: &str) -> bool {
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 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 a
/// 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 image can't spin us. v0.5.9; reader-generic since v0.7.4.
async fn detect_el_torito<R: IsoReadAt + Send>(r: &mut R) -> bool {
for lba in 16u64..32 {
let Ok(vd) = r.read_at(lba * SECTOR, 2048).await else {
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)]
mod tests {
use super::*;
use crate::iso_fs::testiso::{MemReadAt, TestIsoBuilder};
fn introspect_mem(img: Vec<u8>, filename: &str, bulk: bool) -> IntrospectionReport {
let len = img.len() as u64;
futures::executor::block_on(introspect_reader(&mut MemReadAt(img), len, filename, bulk))
}
#[test]
fn label_matching() {
@@ -525,158 +241,13 @@ mod tests {
DistroFamily::OpenSuse
);
assert_eq!(family_from_label("ARCH_202604"), DistroFamily::Arch);
assert_eq!(
family_from_label("GParted-live"),
DistroFamily::DebianUbuntu
);
assert_eq!(family_from_label("rhcos-417"), DistroFamily::RhelFedora);
assert_eq!(family_from_label("weird-custom"), DistroFamily::Unknown);
}
#[test]
fn gparted_shape_is_not_windows() {
// The v0.7.4 regression test: a Debian-live image whose payload
// contains the literal string "bootmgr" (as GRUB/syslinux
// chainload modules do). The old byte-grep classified this as
// WindowsPe; the probe order must classify Debian first.
let img = TestIsoBuilder::new("GParted-live")
.el_torito(true)
.file("/live/vmlinuz", b"KERNEL")
.file("/live/initrd.img", b"INITRD")
.file("/boot/grub/chain.mod", b"xxx bootmgr xxx")
.build();
let r = introspect_mem(img, "gparted-live-1.8.1-3-amd64.iso", true);
assert_eq!(r.family, DistroFamily::DebianUbuntu);
// Classification only — live-boot args aren't rendered yet, so no
// kernel entry; sanboot (via el_torito) keeps working.
assert!(r.kernel_path.is_none());
assert!(r.el_torito);
assert_eq!(r.introspect_rev, INTROSPECT_REV);
}
#[test]
fn casper_shape_verifies_kernel_and_initrd() {
let img = TestIsoBuilder::new("Ubuntu-Server 24.04.1 LTS amd64")
.el_torito(true)
.file("/casper/vmlinuz", b"K")
.file("/casper/initrd", b"I")
.build();
let r = introspect_mem(img, "ubuntu-24.04.1-live-server-amd64.iso", true);
assert_eq!(r.family, DistroFamily::DebianUbuntu);
assert_eq!(r.kernel_path.as_deref(), Some("/casper/vmlinuz"));
assert_eq!(r.initrd_paths, vec!["/casper/initrd".to_string()]);
}
#[test]
fn anaconda_shape_emits_verified_paths() {
let img = TestIsoBuilder::new("AlmaLinux-9-5-x86_64-dvd")
.el_torito(true)
.file("/images/pxeboot/vmlinuz", b"K")
.file("/images/pxeboot/initrd.img", b"I")
.build();
let r = introspect_mem(img, "AlmaLinux-9.5-x86_64-dvd.iso", true);
assert_eq!(r.family, DistroFamily::RhelFedora);
assert_eq!(r.kernel_path.as_deref(), Some("/images/pxeboot/vmlinuz"));
}
#[test]
fn coreos_shape_classifies_but_keeps_sanboot() {
// RHCOS / OpenShift agent ISOs: anaconda layout + rootfs.img.
// Direct kernel boot needs coreos.live.rootfs_url (and agent
// ISOs their embedded ignition), so kernel_path must stay None.
let img = TestIsoBuilder::new("rhcos-417.94.202501")
.el_torito(true)
.file("/images/pxeboot/vmlinuz", b"K")
.file("/images/pxeboot/initrd.img", b"I")
.file("/images/pxeboot/rootfs.img", b"R")
.build();
let r = introspect_mem(img, "rhcos-live.x86_64.iso", true);
assert_eq!(r.family, DistroFamily::RhelFedora);
assert!(
r.kernel_path.is_none(),
"CoreOS must not get a kernel entry"
);
assert!(r.el_torito);
}
#[test]
fn boot_wim_probe_classifies_windows() {
let img = TestIsoBuilder::new("CCCOMA_X64FRE_EN-US_DV9")
.el_torito(true)
.file("/sources/boot.wim", b"MSWIMMSWIM")
.build();
let r = introspect_mem(img, "whatever.iso", false);
assert_eq!(r.family, DistroFamily::WindowsPe);
assert!(r.has_boot_wim);
}
#[test]
fn label_only_linux_without_verified_kernel_gets_no_kernel_path() {
// Label says RHEL but the tree has no pxeboot files — the old
// code guessed `/images/pxeboot/vmlinuz` and emitted an entry
// that 404'd at boot. Now: family yes, kernel paths no.
let img = TestIsoBuilder::new("RHEL-9-5-CUSTOM")
.el_torito(true)
.file("/readme.txt", b"hi")
.build();
let r = introspect_mem(img, "rhel-custom.iso", true);
assert_eq!(r.family, DistroFamily::RhelFedora);
assert!(r.kernel_path.is_none());
assert!(r.initrd_paths.is_empty());
}
#[test]
fn remote_skips_bulk_scan_but_filename_still_hints() {
// No ISO9660 signatures at all (e.g. pure-UDF image read over a
// share), bulk scan off: filename is the only signal.
let img = vec![0u8; 64 * 1024];
let r = introspect_mem(img.clone(), "Win11_24H2_English_x64.iso", false);
assert_eq!(r.family, DistroFamily::WindowsPe);
let r2 = introspect_mem(img, "mystery.iso", false);
assert_eq!(r2.family, DistroFamily::Unknown);
}
#[test]
fn filename_family_table() {
use DistroFamily::*;
let cases = [
("AlmaLinux-9.5-x86_64-dvd.iso", RhelFedora),
("CentOS-Stream-10-latest-x86_64-dvd1.iso", RhelFedora),
("rhel-9.0-x86_64-boot.iso", RhelFedora),
("rhcos-live.x86_64.iso", RhelFedora),
("openshift-4-21-9.agent.x86_64.iso", RhelFedora),
("ubuntu-24.04-desktop.iso", DebianUbuntu),
("gparted-live-1.8.1-3-amd64.iso", DebianUbuntu),
("archlinux-2026.05.01-x86_64.iso", Arch),
("arch-2026.05.01.iso", Arch),
("alpine-standard-3.21.0-x86_64.iso", Alpine),
("openSUSE-Leap-15.6-DVD-x86_64.iso", OpenSuse),
("en-us_windows_11_iot_enterprise.iso", WindowsPe),
("Win10_22H2_English_x64.iso", WindowsPe),
("netboot.xyz.iso", Unknown),
("ise-3.2.0.542a.SPA.x86_64.iso", Unknown),
("Macrium_5860_v2.iso", Unknown),
// "search" must not trip the "arch" token.
("research-data.iso", Unknown),
];
for (name, want) in cases {
assert_eq!(family_from_filename(name), want, "{name}");
}
}
#[test]
fn provisional_report_keeps_rev_zero() {
let r = provisional_report("rhel-9.0-x86_64-dvd.iso");
assert_eq!(r.family, DistroFamily::RhelFedora);
assert_eq!(
r.introspect_rev, 0,
"provisional must keep optimistic sanboot"
);
assert!(!r.el_torito);
}
#[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];
@@ -685,41 +256,25 @@ mod tests {
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_detected_through_reader() {
let with = TestIsoBuilder::new("BOOTABLE").el_torito(true).build();
let without = TestIsoBuilder::new("DATA").build();
assert!(futures::executor::block_on(detect_el_torito(
&mut MemReadAt(with)
)));
assert!(!futures::executor::block_on(detect_el_torito(
&mut MemReadAt(without)
)));
}
#[test]
fn filename_hint_catches_windows_isos() {
assert!(filename_looks_windows(
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("Win10_22H2_English_x64.iso"));
assert!(filename_looks_windows("winserver2022.iso"));
assert!(!filename_looks_windows("ubuntu-24.04-desktop.iso"));
assert!(!filename_looks_windows("Rocky-9.4-x86_64-dvd.iso"));
}
#[test]
fn bulk_scan_catches_udf_windows_markers() {
// A blob with no ISO9660 tree but a UTF-16 "install.wim" — the
// UDF Windows shape after every probe missed.
let mut img = vec![0u8; 256 * 1024];
let marker: Vec<u8> = "install.wim".bytes().flat_map(|b| [b, 0]).collect();
img[100_000..100_000 + marker.len()].copy_from_slice(&marker);
let r = introspect_mem(img, "renamed.iso", true);
assert_eq!(r.family, DistroFamily::WindowsPe);
assert!(!r.has_boot_wim);
)));
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"
)));
}
}
-568
View File
@@ -1,568 +0,0 @@
//! Read-only ISO9660 lookup over any random-access byte source.
//!
//! v0.7.4: generalized from the http-api crate's local-file-only walker so
//! the same directory walk drives three consumers:
//!
//! 1. `iso_file` HTTP serving — locate `/casper/vmlinuz` inside a local
//! *or remote* (NFS/SFTP) ISO and stream just that byte range.
//! 2. Introspection — probe for well-known kernel/initrd/boot.wim paths
//! instead of grepping raw sectors for filename strings (which
//! false-positived: any Linux ISO shipping GRUB/syslinux chainload
//! modules contains the literal "bootmgr" and used to classify as
//! Windows).
//! 3. Remote introspection — the same probes over an NFSv3 READ3 /
//! SFTP seek-read connection, which is what finally classifies
//! share-sourced ISOs instead of registering them all as `Unknown`.
//!
//! We parse only the Primary Volume Descriptor namespace. Joliet and Rock
//! Ridge are deliberately ignored — matching is case-insensitive against
//! plain ISO9660 identifiers (`;1` version suffix and the trailing dot of
//! extension-less strict-mastered names stripped), which is how the local
//! serving path has always behaved in production.
use std::collections::HashMap;
use std::future::Future;
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
pub const SECTOR: u64 = 2048;
/// Upper bound on a single directory extent we'll buffer. Real distro ISO
/// directories are a handful of KiB; the cap keeps a malformed or hostile
/// image from asking us to allocate gigabytes.
const MAX_DIR_BYTES: u64 = 4 * 1024 * 1024;
/// Byte range of one file inside the ISO image.
#[derive(Debug, Clone)]
pub struct FileLocation {
pub offset: u64,
pub length: u64,
}
/// Random-access reads into an ISO image. Implemented by a local
/// `std::fs::File`, the NFS and SFTP share readers, and the in-memory
/// test image.
///
/// The contract is `read_exact`-like: the returned buffer is exactly
/// `len` bytes or the call errors. The future must be `Send` because
/// remote introspection runs inside spawned tokio tasks.
pub trait IsoReadAt {
fn read_at(
&mut self,
offset: u64,
len: u32,
) -> impl Future<Output = std::io::Result<Vec<u8>>> + Send;
}
/// Local-file reader. The reads are synchronous inside an async fn —
/// callers run it either on the blocking pool (introspection at upload)
/// or through [`lookup_local`]'s `block_on`, never on a hot runtime
/// worker with real awaits pending.
pub struct FileReadAt(std::fs::File);
impl FileReadAt {
#[must_use]
pub fn new(f: std::fs::File) -> Self {
Self(f)
}
}
impl IsoReadAt for FileReadAt {
async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result<Vec<u8>> {
self.0.seek(SeekFrom::Start(offset))?;
let mut buf = vec![0u8; len as usize];
self.0.read_exact(&mut buf)?;
Ok(buf)
}
}
/// Exact-key read cache for the probe phase of introspection. The probe
/// table looks up ~20 paths and every one of them re-reads the root
/// directory (and usually one shared subdirectory); over NFS/SFTP that
/// would be 20 identical round-trips. Directory reads repeat with the
/// exact same `(offset, len)`, so a plain map keyed on the pair hits
/// every time. Large data reads bypass the cache.
pub struct CachingReadAt<'a, R: IsoReadAt + Send> {
inner: &'a mut R,
cache: HashMap<(u64, u32), Vec<u8>>,
}
/// Don't cache reads bigger than this (file payloads, bulk scans).
const CACHE_MAX_READ: u32 = 256 * 1024;
/// Bound the cache so a pathological image can't grow it unbounded.
const CACHE_MAX_ENTRIES: usize = 256;
impl<'a, R: IsoReadAt + Send> CachingReadAt<'a, R> {
pub fn new(inner: &'a mut R) -> Self {
Self {
inner,
cache: HashMap::new(),
}
}
}
impl<R: IsoReadAt + Send> IsoReadAt for CachingReadAt<'_, R> {
async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result<Vec<u8>> {
let key = (offset, len);
if let Some(hit) = self.cache.get(&key) {
return Ok(hit.clone());
}
let buf = self.inner.read_at(offset, len).await?;
if len <= CACHE_MAX_READ && self.cache.len() < CACHE_MAX_ENTRIES {
self.cache.insert(key, buf.clone());
}
Ok(buf)
}
}
/// Look up `in_iso_path` (leading slash optional, case-insensitive) in
/// the image behind `r`. Returns `None` on any parsing or IO failure —
/// "not found" and "couldn't read" are the same answer to a prober.
pub async fn lookup<R: IsoReadAt + Send>(r: &mut R, in_iso_path: &str) -> Option<FileLocation> {
let pvd = r.read_at(16 * SECTOR, 2048).await.ok()?;
if pvd[0] != 0x01 || &pvd[1..6] != b"CD001" {
return None;
}
// Root directory record at PVD offset 156, 34 bytes.
let (mut lba, mut len) = parse_dir_record_ext(&pvd[156..156 + 34])?;
let components: Vec<&str> = in_iso_path
.trim_start_matches('/')
.split('/')
.filter(|c| !c.is_empty())
.collect();
if components.is_empty() {
return None;
}
// The original walk was tail-recursive; iterate instead so the future
// stays a plain (non-boxed) state machine.
for (idx, comp) in components.iter().enumerate() {
if len == 0 || len > MAX_DIR_BYTES {
return None;
}
let dir = r.read_at(lba * SECTOR, len as u32).await.ok()?;
let hit = scan_dir(&dir, comp)?;
let last = idx + 1 == components.len();
match (last, hit.is_dir) {
(true, false) => {
return Some(FileLocation {
offset: hit.lba * SECTOR,
length: hit.len,
})
}
(false, true) => {
lba = hit.lba;
len = hit.len;
}
_ => return None,
}
}
None
}
/// Convenience probe: does `in_iso_path` exist as a file?
pub async fn exists<R: IsoReadAt + Send>(r: &mut R, in_iso_path: &str) -> bool {
lookup(r, in_iso_path).await.is_some()
}
/// Synchronous wrapper for local files — the shape the HTTP handler's
/// `spawn_blocking` call site wants. `block_on` is safe here because
/// `FileReadAt`'s reads never actually await (they complete inline), so
/// the executor never parks.
#[must_use]
pub fn lookup_local(iso_path: &Path, in_iso_path: &str) -> Option<FileLocation> {
let f = std::fs::File::open(iso_path).ok()?;
futures::executor::block_on(lookup(&mut FileReadAt::new(f), in_iso_path))
}
struct DirHit {
lba: u64,
len: u64,
is_dir: bool,
}
/// Scan one directory extent for an identifier. Pure function over the
/// buffered extent — all protocol/IO concerns live in the caller.
fn scan_dir(dir: &[u8], target: &str) -> Option<DirHit> {
let mut i = 0;
while i < dir.len() {
let len = dir[i] as usize;
if len == 0 {
// Records never span sectors; a zero length byte means the
// rest of this sector is padding. Hop to the next one.
let next = (i / SECTOR as usize + 1) * SECTOR as usize;
if next <= i {
break;
}
i = next;
continue;
}
if i + len > dir.len() {
break;
}
let rec = &dir[i..i + len];
let name = dir_record_name(rec);
let is_dir = (rec.get(25).copied().unwrap_or(0) & 0x02) != 0;
// Skip "." (0x00) and ".." (0x01) pseudo-entries.
let is_pseudo =
rec.get(32).copied() == Some(1) && matches!(rec.get(33).copied(), Some(0x00 | 0x01));
if !is_pseudo && name.eq_ignore_ascii_case(target) {
let (lba, dlen) = parse_dir_record_ext(rec)?;
return Some(DirHit {
lba,
len: dlen,
is_dir,
});
}
i += len;
}
None
}
/// Extract (extent LBA, data length in bytes) from a directory record.
/// Layout per ISO9660: bytes 2..10 extent LBA (LE+BE duplicate), 10..18
/// data length (LE+BE duplicate). We trust the little-endian copy.
fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> {
if rec.len() < 34 {
return None;
}
let lba = u64::from(u32::from_le_bytes(rec[2..6].try_into().ok()?));
let len = u64::from(u32::from_le_bytes(rec[10..14].try_into().ok()?));
Some((lba, len))
}
/// Extract the identifier from a directory record, normalizing ISO9660
/// quirks: the `;N` version suffix and the trailing dot that strict
/// mastering appends to extension-less names (`VMLINUZ.;1`). Without the
/// dot strip, level-1 images' kernels never matched `/casper/vmlinuz`.
fn dir_record_name(rec: &[u8]) -> String {
let name_len = *rec.get(32).unwrap_or(&0) as usize;
if name_len == 0 || rec.len() < 33 + name_len {
return String::new();
}
let raw = &rec[33..33 + name_len];
let s = String::from_utf8_lossy(raw);
let s = s.rfind(';').map_or_else(|| s.as_ref(), |i| &s[..i]);
s.strip_suffix('.').unwrap_or(s).to_string()
}
// ── test support ─────────────────────────────────────────────────────
//
// A tiny ISO9660 image builder used by this module's tests, the
// introspection tests, and (behind the `test-image` feature) other
// crates' integration tests. Lays out: PVD @ LBA 16, optional El Torito
// boot record @ 17, set terminator @ 18, directories from LBA 20, file
// data after. Only what `lookup`/introspection read is populated.
#[cfg(any(test, feature = "test-image"))]
#[doc(hidden)]
pub mod testiso {
use super::SECTOR;
use std::collections::BTreeMap;
#[derive(Default)]
struct Node {
children: BTreeMap<String, Node>,
content: Option<Vec<u8>>,
}
pub struct TestIsoBuilder {
root: Node,
volume_label: String,
el_torito: bool,
}
impl TestIsoBuilder {
pub fn new(volume_label: &str) -> Self {
Self {
root: Node::default(),
volume_label: volume_label.to_string(),
el_torito: false,
}
}
#[must_use]
pub fn el_torito(mut self, on: bool) -> Self {
self.el_torito = on;
self
}
/// Add a file at `path` (e.g. "/casper/vmlinuz") with `content`.
#[must_use]
pub fn file(mut self, path: &str, content: &[u8]) -> Self {
let mut node = &mut self.root;
let comps: Vec<&str> = path
.trim_start_matches('/')
.split('/')
.filter(|c| !c.is_empty())
.collect();
for (i, c) in comps.iter().enumerate() {
node = node.children.entry((*c).to_string()).or_default();
if i + 1 == comps.len() {
node.content = Some(content.to_vec());
}
}
self
}
pub fn build(self) -> Vec<u8> {
// Pass 1: allocate extents. Directories first (1 sector each),
// then file contents.
let mut next_lba: u64 = 20;
let mut dirs: Vec<(*const Node, u64)> = Vec::new();
fn alloc_dirs(n: &Node, next: &mut u64, out: &mut Vec<(*const Node, u64)>) {
out.push((std::ptr::from_ref(n), *next));
*next += 1;
for child in n.children.values() {
if child.content.is_none() {
alloc_dirs(child, next, out);
}
}
}
alloc_dirs(&self.root, &mut next_lba, &mut dirs);
let lba_of = |n: &Node| -> u64 {
dirs.iter()
.find(|(p, _)| std::ptr::eq(*p, n))
.map(|(_, l)| *l)
.expect("dir allocated")
};
let mut file_lbas: Vec<(*const Node, u64, usize)> = Vec::new();
fn alloc_files(n: &Node, next: &mut u64, out: &mut Vec<(*const Node, u64, usize)>) {
for child in n.children.values() {
if let Some(c) = &child.content {
out.push((std::ptr::from_ref(child), *next, c.len()));
*next += c.len().div_ceil(SECTOR as usize).max(1) as u64;
} else {
alloc_files(child, next, out);
}
}
}
alloc_files(&self.root, &mut next_lba, &mut file_lbas);
let file_lba_of = |n: &Node| -> u64 {
file_lbas
.iter()
.find(|(p, _, _)| std::ptr::eq(*p, n))
.map(|(_, l, _)| *l)
.expect("file allocated")
};
let total = next_lba as usize * SECTOR as usize;
let mut img = vec![0u8; total];
// Directory record encoder.
fn record(name_bytes: &[u8], lba: u64, len: u64, is_dir: bool) -> Vec<u8> {
let mut rec_len = 33 + name_bytes.len();
if rec_len % 2 == 1 {
rec_len += 1; // pad to even
}
let rec_len = rec_len.max(34);
let mut r = vec![0u8; rec_len];
r[0] = rec_len as u8;
r[2..6].copy_from_slice(&(lba as u32).to_le_bytes());
r[6..10].copy_from_slice(&(lba as u32).to_be_bytes());
r[10..14].copy_from_slice(&(len as u32).to_le_bytes());
r[14..18].copy_from_slice(&(len as u32).to_be_bytes());
if is_dir {
r[25] = 0x02;
}
r[32] = name_bytes.len() as u8;
r[33..33 + name_bytes.len()].copy_from_slice(name_bytes);
r
}
// Pass 2: write each directory extent.
fn write_dir(
img: &mut [u8],
n: &Node,
self_lba: u64,
parent_lba: u64,
lba_of: &dyn Fn(&Node) -> u64,
file_lba_of: &dyn Fn(&Node) -> u64,
) {
let base = self_lba as usize * SECTOR as usize;
let mut off = 0usize;
let mut put = |rec: Vec<u8>, off: &mut usize| {
img[base + *off..base + *off + rec.len()].copy_from_slice(&rec);
*off += rec.len();
};
put(record(&[0x00], self_lba, SECTOR, true), &mut off);
put(record(&[0x01], parent_lba, SECTOR, true), &mut off);
for (name, child) in &n.children {
if let Some(c) = &child.content {
// Files get the ISO9660 uppercase `;1` treatment so
// the case-insensitive + version-strip matching is
// what the tests actually exercise.
let stored = format!("{};1", name.to_ascii_uppercase());
put(
record(stored.as_bytes(), file_lba_of(child), c.len() as u64, false),
&mut off,
);
} else {
let stored = name.to_ascii_uppercase();
put(
record(stored.as_bytes(), lba_of(child), SECTOR, true),
&mut off,
);
}
}
for (name, child) in &n.children {
if child.content.is_none() {
write_dir(img, child, lba_of(child), self_lba, lba_of, file_lba_of);
} else if let Some(c) = &child.content {
let b = file_lba_of(child) as usize * SECTOR as usize;
img[b..b + c.len()].copy_from_slice(c);
}
let _ = name;
}
}
let root_lba = lba_of(&self.root);
write_dir(
&mut img,
&self.root,
root_lba,
root_lba,
&lba_of,
&file_lba_of,
);
// PVD @ 16.
let pvd = 16 * SECTOR as usize;
img[pvd] = 0x01;
img[pvd + 1..pvd + 6].copy_from_slice(b"CD001");
let label = self.volume_label.as_bytes();
let label_field = &mut img[pvd + 40..pvd + 72];
label_field.fill(b' ');
label_field[..label.len().min(32)].copy_from_slice(&label[..label.len().min(32)]);
let root_rec = record(&[0x00], root_lba, SECTOR, true);
img[pvd + 156..pvd + 156 + 34].copy_from_slice(&root_rec[..34]);
// Optional El Torito boot record @ 17, terminator after.
let mut vd = 17 * SECTOR as usize;
if self.el_torito {
img[vd] = 0x00;
img[vd + 1..vd + 6].copy_from_slice(b"CD001");
let id = b"EL TORITO SPECIFICATION";
img[vd + 7..vd + 7 + id.len()].copy_from_slice(id);
vd += SECTOR as usize;
}
img[vd] = 0xFF;
img[vd + 1..vd + 6].copy_from_slice(b"CD001");
img
}
}
/// In-memory `IsoReadAt` over a built test image.
pub struct MemReadAt(pub Vec<u8>);
impl super::IsoReadAt for MemReadAt {
async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result<Vec<u8>> {
let start = usize::try_from(offset).unwrap_or(usize::MAX);
let end = start.saturating_add(len as usize);
if end > self.0.len() {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"read past end of test image",
));
}
Ok(self.0[start..end].to_vec())
}
}
}
#[cfg(test)]
mod tests {
use super::testiso::{MemReadAt, TestIsoBuilder};
use super::*;
fn block_on<T>(f: impl Future<Output = T>) -> T {
futures::executor::block_on(f)
}
#[test]
fn lookup_finds_nested_file_case_insensitively() {
let img = TestIsoBuilder::new("UBUNTU 24.04")
.file("/casper/vmlinuz", b"KERNELDATA")
.file("/casper/initrd", b"INITRDDATA")
.build();
let mut r = MemReadAt(img);
let loc = block_on(lookup(&mut r, "/CASPER/VMLINUZ")).expect("found");
assert_eq!(loc.length, 10);
let bytes = block_on(r.read_at(loc.offset, 10)).unwrap();
assert_eq!(&bytes, b"KERNELDATA");
// Missing file and missing dir both miss cleanly.
assert!(block_on(lookup(&mut r, "/casper/missing")).is_none());
assert!(block_on(lookup(&mut r, "/nodir/vmlinuz")).is_none());
// A directory path that resolves to a directory is not a file hit.
assert!(block_on(lookup(&mut r, "/casper")).is_none());
}
#[test]
fn strict_mastered_extensionless_names_match() {
// Strict level-1 mastering stores "VMLINUZ" as "VMLINUZ.;1" — the
// trailing dot must be normalized away or kernels never match.
let img = TestIsoBuilder::new("STRICT")
.file("/boot/vmlinuz.", b"K") // builder stores "VMLINUZ.;1"
.build();
let mut r = MemReadAt(img);
assert!(
block_on(lookup(&mut r, "/boot/vmlinuz")).is_some(),
"trailing-dot ISO9660 name must match the dotless path"
);
}
#[test]
fn lookup_three_levels_deep() {
let img = TestIsoBuilder::new("DEEP")
.file("/images/pxeboot/vmlinuz", b"ANACONDA")
.build();
let mut r = MemReadAt(img);
let loc = block_on(lookup(&mut r, "images/pxeboot/vmlinuz")).expect("no leading slash ok");
assert_eq!(loc.length, 8);
}
#[test]
fn caching_reader_dedupes_repeated_directory_reads() {
struct Counting<'a> {
inner: &'a mut MemReadAt,
calls: usize,
}
impl IsoReadAt for Counting<'_> {
async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result<Vec<u8>> {
self.calls += 1;
self.inner.read_at(offset, len).await
}
}
let img = TestIsoBuilder::new("CACHE")
.file("/a/one", b"1")
.file("/a/two", b"2")
.build();
let mut mem = MemReadAt(img);
let mut counting = Counting {
inner: &mut mem,
calls: 0,
};
let mut cr = CachingReadAt::new(&mut counting);
assert!(block_on(exists(&mut cr, "/a/one")));
assert!(block_on(exists(&mut cr, "/a/two")));
assert!(!block_on(exists(&mut cr, "/a/three")));
drop(cr);
// 3 probes × (PVD + root dir + subdir) = 9 uncached; the cache
// collapses the repeats to the 3 distinct extents.
assert_eq!(counting.calls, 3, "all repeat reads must hit the cache");
}
#[test]
fn lookup_local_reads_a_real_file() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("t.iso");
let img = TestIsoBuilder::new("LOCAL")
.file("/sources/boot.wim", b"WIMWIM")
.build();
std::fs::write(&p, &img).unwrap();
let loc = lookup_local(&p, "/sources/boot.wim").expect("found");
assert_eq!(loc.length, 6);
assert!(lookup_local(&p, "/sources/none").is_none());
}
}
-8
View File
@@ -18,15 +18,8 @@
pub mod entry;
pub mod introspect;
// v0.7.4: read-only ISO9660 walker generic over any random-access byte
// source (local file, NFS READ3, SFTP seek-read). Powers both in-ISO
// HTTP serving and the probe-based introspection.
pub mod iso_fs;
pub mod nfs_share;
pub mod pxe_logo;
// v0.7.4: persisted cache of remote-share introspection results so a
// container restart doesn't re-probe an unchanged 40-ISO library.
pub mod remote_cache;
pub mod sftp_share;
pub mod smb;
pub mod smb_share;
@@ -36,7 +29,6 @@ pub mod windows;
pub use entry::{BootEntry, BootKind, KernelArgs};
pub use introspect::{DistroFamily, IntrospectionReport};
pub use iso_fs::FileLocation;
// v0.4.65: kernel-mount NFS is gone. SMB shares via Samba's userspace
// `smbclient` CLI replaced it — works in any container (no
// CAP_SYS_ADMIN, no host kernel modules), matching how Bootimus and
+29 -221
View File
@@ -57,16 +57,14 @@
//! 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.)
use crate::introspect::{introspect_reader, provisional_report};
use crate::iso_fs::{self, FileLocation, IsoReadAt};
use crate::remote_cache::RemoteIntrospectCache;
use crate::introspect::{DistroFamily, IntrospectionReport};
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
use bytes::Bytes;
use nfs3_client::tokio::TokioConnector;
use nfs3_client::Nfs3ConnectionBuilder;
use nfs3_types::nfs3::{
self as nfs3, diropargs3, entry3, filename3, nfs_fh3, GETATTR3args, LOOKUP3args, Nfs3Result,
READ3args, READDIR3args,
self as nfs3, diropargs3, entry3, filename3, nfs_fh3, GETATTR3args, LOOKUP3args,
Nfs3Result, READ3args, READDIR3args,
};
use nfs3_types::rpc::{auth_unix, opaque_auth};
use nfs3_types::xdr_codec::Opaque;
@@ -102,18 +100,6 @@ const READ_CHUNK_BYTES: u32 = 64 * 1024;
/// client park gigabytes of decoded ISO in RAM.
const STREAM_BUFFER_DEPTH: usize = 16;
/// v0.7.4: per-ISO budget for a background introspection probe. A probe
/// is one connection plus a few dozen KiB-sized reads — sub-second on a
/// LAN — so anything past this is a wedged server, not a slow one.
const INTROSPECT_TIMEOUT: Duration = Duration::from_secs(30);
/// One queued background-introspection unit (v0.7.4).
struct ProbeJob {
iso_id: String,
filename: String,
size: u64,
}
/// One configured NFS share. The id is derived from server+export so
/// re-adding the same coordinates is idempotent.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -191,9 +177,6 @@ pub struct NfsShareManager {
/// opens its own NFS connection so concurrency isn't a hard
/// requirement, but serializing keeps log output predictable.
op_lock: Arc<tokio::sync::Mutex<()>>,
/// v0.7.4: persisted introspection results keyed `share/path@size`,
/// so a restart re-probes only new or replaced ISOs.
introspect_cache: RemoteIntrospectCache,
}
impl NfsShareManager {
@@ -207,7 +190,6 @@ impl NfsShareManager {
inner: Arc::new(Mutex::new(Inner::default())),
iso_store,
op_lock: Arc::new(tokio::sync::Mutex::new(())),
introspect_cache: RemoteIntrospectCache::open(work_dir, "nfs_introspect_cache.json"),
}
}
@@ -238,7 +220,10 @@ impl NfsShareManager {
/// Register an NFS share. Validates, probes connectivity by
/// performing a real MOUNT3 + READDIR3, and registers the
/// resulting ISOs with the store.
pub async fn add(&self, req: NfsAddRequest) -> std::result::Result<NfsShare, NfsShareError> {
pub async fn add(
&self,
req: NfsAddRequest,
) -> std::result::Result<NfsShare, NfsShareError> {
let server = normalize_server(&req.server);
let export = req.export.trim().to_string();
if server.is_empty() {
@@ -273,9 +258,7 @@ impl NfsShareManager {
if let Err(e) = self.rescan_inner(&id).await {
let m = self.get(&id);
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()),
stderr: String::new(),
hint: m.and_then(|m| m.last_hint),
@@ -350,7 +333,8 @@ impl NfsShareManager {
return Err(Error::Invalid(format!("invalid filename '{filename}'")));
}
let (tx, rx) = tokio::sync::mpsc::channel::<std::io::Result<Bytes>>(STREAM_BUFFER_DEPTH);
let (tx, rx) =
tokio::sync::mpsc::channel::<std::io::Result<Bytes>>(STREAM_BUFFER_DEPTH);
let server = share.server.clone();
let export = share.export.clone();
let port = share.port;
@@ -374,11 +358,16 @@ impl NfsShareManager {
if let Err(e) = result {
// Best-effort signal of the error to the consumer.
// If the receiver has already dropped we just exit.
let _ = tx.send(Err(std::io::Error::other(e.to_string()))).await;
let _ = tx
.send(Err(std::io::Error::other(e.to_string())))
.await;
}
});
Ok(NfsStream { rx, _task: task })
Ok(NfsStream {
rx,
_task: task,
})
}
// ── internals ─────────────────────────────────────────────────────
@@ -405,26 +394,18 @@ impl NfsShareManager {
};
let mut count = 0u32;
let mut to_probe: Vec<ProbeJob> = Vec::new();
for entry in listing {
let iso_id = format!("nfs-{}-{}", share.id, slugify_str(&entry.filename));
// v0.7.4: real introspection over the share — NFSv3 READ3
// takes an offset, so the ISO9660 probes work remotely. A
// cache hit registers the full report immediately; a miss
// registers a provisional filename-based report (so the scan
// returns fast) and queues a background probe that upgrades
// the entry in place.
let cached = self
.introspect_cache
.get(&share.id, &entry.filename, entry.size);
let report = cached.unwrap_or_else(|| {
to_probe.push(ProbeJob {
iso_id: iso_id.clone(),
filename: entry.filename.clone(),
size: entry.size,
});
provisional_report(&entry.filename)
});
// Same approach as SMB: no real introspection over the
// network in v0.4.67. The boot-entry generator falls back
// to filename-based sanboot detection.
let report = IntrospectionReport {
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 source = IsoSource::Nfs {
share_id: share.id.clone(),
@@ -445,88 +426,11 @@ impl NfsShareManager {
target: "openpxe::nfs",
id = %id, server = %share.server, export = %share.export,
iso_count = count,
pending_introspection = to_probe.len(),
"NFS share scanned"
);
if !to_probe.is_empty() {
self.spawn_introspection_pass(&share, to_probe);
}
Ok(count)
}
/// v0.7.4: probe each queued ISO over its own NFS connection and swap
/// the full introspection into the store as results land. Runs
/// detached so neither startup nor the share-add API call waits on a
/// 40-ISO library; per-ISO failures (or a share removed mid-pass)
/// leave the provisional entry in place, which still sanboots.
fn spawn_introspection_pass(&self, share: &NfsShare, work: Vec<ProbeJob>) {
let store = self.iso_store.clone();
let cache = self.introspect_cache.clone();
let share_id = share.id.clone();
let server = share.server.clone();
let export = share.export.clone();
let port = share.port;
let queued = work.len();
tokio::spawn(async move {
let mut upgraded = 0usize;
for job in work {
let probe = async {
let mut reader = NfsReadAt::open(&server, &export, port, &job.filename).await?;
let report =
introspect_reader(&mut reader, job.size, &job.filename, false).await;
reader.finish().await;
Ok::<_, NfsClientError>(report)
};
match tokio::time::timeout(INTROSPECT_TIMEOUT, probe).await {
Ok(Ok(report)) => {
cache.put(&share_id, &job.filename, job.size, report.clone());
if store.update_external_introspection(&job.iso_id, report) {
upgraded += 1;
}
}
Ok(Err(e)) => tracing::warn!(
target: "openpxe::nfs",
share = %share_id, iso = %job.filename,
"introspection failed: {e}"
),
Err(_) => tracing::warn!(
target: "openpxe::nfs",
share = %share_id, iso = %job.filename,
"introspection timed out after {}s", INTROSPECT_TIMEOUT.as_secs()
),
}
}
tracing::info!(
target: "openpxe::nfs",
share = %share_id, queued, upgraded,
"remote introspection pass complete"
);
});
}
/// v0.7.4: locate `in_iso_path` inside a share-hosted ISO. Returns the
/// byte range so the HTTP layer can serve kernel/initrd files out of
/// remote ISOs with a follow-up ranged [`Self::stream_iso`].
pub async fn locate_in_iso(
&self,
share_id: &str,
filename: &str,
in_iso_path: &str,
) -> Result<Option<FileLocation>> {
let share = self
.get(share_id)
.ok_or_else(|| Error::Invalid(format!("no such NFS share '{share_id}'")))?;
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return Err(Error::Invalid(format!("invalid filename '{filename}'")));
}
let mut reader = NfsReadAt::open(&share.server, &share.export, share.port, filename)
.await
.map_err(|e| Error::Invalid(format!("nfs open '{filename}': {e}")))?;
let loc = iso_fs::lookup(&mut reader, in_iso_path).await;
reader.finish().await;
Ok(loc)
}
fn update_status(
&self,
id: &str,
@@ -599,96 +503,6 @@ struct NfsListEntry {
size: u64,
}
/// The connection type [`build_connection`] yields.
type NfsConn = nfs3_client::Nfs3Connection<nfs3_client::tokio::TokioIo<tokio::net::TcpStream>>;
/// v0.7.4: random-access reader over one NFS connection + file handle —
/// the [`IsoReadAt`] impl that lets the ISO9660 walker and introspection
/// probes run against share-hosted images.
struct NfsReadAt {
conn: NfsConn,
fh: nfs_fh3,
}
impl NfsReadAt {
/// Connect, mount, and LOOKUP `filename` at the export root.
async fn open(
server: &str,
export: &str,
port: u16,
filename: &str,
) -> std::result::Result<Self, NfsClientError> {
let mut conn = build_connection(server, export, port).await?;
let root = conn.root_nfs_fh3();
let lookup = conn
.lookup(&LOOKUP3args {
what: diropargs3 {
dir: root,
name: filename3(Opaque::borrowed(filename.as_bytes())),
},
})
.await
.map_err(NfsClientError::Rpc)?;
let fh = match lookup {
Nfs3Result::Ok(o) => o.object,
Nfs3Result::Err((status, _)) => {
return Err(NfsClientError::Nfsstat(status_label(status)));
}
};
Ok(Self { conn, fh })
}
/// Best-effort unmount. Consumes the reader — it's done.
async fn finish(self) {
let _ = self.conn.unmount().await;
}
}
impl IsoReadAt for NfsReadAt {
async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result<Vec<u8>> {
let mut out: Vec<u8> = Vec::with_capacity(len as usize);
let mut off = offset;
// READ3 may legally return fewer bytes than asked (server cap);
// loop until the exact-read contract is satisfied or the file
// genuinely ends short.
while (out.len() as u32) < len {
let want = (len - out.len() as u32).min(READ_CHUNK_BYTES);
let res = self
.conn
.read(&READ3args {
file: self.fh.clone(),
offset: off,
count: want,
})
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
let ok = match res {
Nfs3Result::Ok(o) => o,
Nfs3Result::Err((status, _)) => {
return Err(std::io::Error::other(status_label(status)));
}
};
let data = ok.data.as_ref();
if data.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"NFS read past end of file",
));
}
out.extend_from_slice(data);
off += data.len() as u64;
if ok.eof && (out.len() as u32) < len {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"NFS read past end of file",
));
}
}
out.truncate(len as usize);
Ok(out)
}
}
/// Connect, READDIR the export root, look up each `*.iso` to get its
/// size + file handle. Returns a flat list. Errors are returned with
/// a human-readable message; the caller decides how to surface them.
@@ -1174,14 +988,8 @@ mod tests {
// the allow-list and the secure/insecure angle.
let h = hint_for("connect failed: MNT3ERR_ACCES").unwrap();
let lc = h.to_lowercase();
assert!(
lc.contains("insecure") || lc.contains("privileged"),
"got: {h}"
);
assert!(
lc.contains("allow") || lc.contains("permission"),
"got: {h}"
);
assert!(lc.contains("insecure") || lc.contains("privileged"), "got: {h}");
assert!(lc.contains("allow") || lc.contains("permission"), "got: {h}");
}
#[test]
-142
View File
@@ -1,142 +0,0 @@
//! Persisted cache of remote-share introspection results.
//!
//! NFS/SFTP introspection costs a connection plus a few dozen small
//! reads per ISO. Shares are rescanned on every startup and share-add,
//! so without a cache a 40-ISO library would re-probe 40 ISOs on every
//! container restart. The cache keys on `share/path@size` — a replaced
//! file (new size) re-probes, an untouched one is free — and entries
//! only count as hits when their `introspect_rev` matches the current
//! logic, so an upgrade that changes detection re-probes everything
//! exactly once.
//!
//! One file per protocol (`nfs_introspect_cache.json`,
//! `sftp_introspect_cache.json`) so the two managers never contend over
//! one writer.
use crate::introspect::{IntrospectionReport, INTROSPECT_REV};
use parking_lot::Mutex;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Hard cap on cached entries; beyond it the cache resets rather than
/// growing unbounded (a cache wipe only costs one re-probe pass).
const MAX_ENTRIES: usize = 4096;
#[derive(Clone, Debug)]
pub struct RemoteIntrospectCache {
path: Arc<PathBuf>,
map: Arc<Mutex<HashMap<String, IntrospectionReport>>>,
}
impl RemoteIntrospectCache {
/// Open (or start empty) the cache at `<work_dir>/<file_name>`.
/// A corrupt or missing file is an empty cache, never an error.
#[must_use]
pub fn open(work_dir: &Path, file_name: &str) -> Self {
let path = work_dir.join(file_name);
let map = std::fs::read_to_string(&path)
.ok()
.and_then(|text| {
serde_json::from_str::<HashMap<String, IntrospectionReport>>(&text).ok()
})
.unwrap_or_default();
Self {
path: Arc::new(path),
map: Arc::new(Mutex::new(map)),
}
}
fn key(share_id: &str, relative_path: &str, size: u64) -> String {
format!("{share_id}/{relative_path}@{size}")
}
/// A hit requires the entry to have been produced by the *current*
/// introspection logic — stale-rev entries are misses, which is how
/// the cache self-invalidates across upgrades.
#[must_use]
pub fn get(
&self,
share_id: &str,
relative_path: &str,
size: u64,
) -> Option<IntrospectionReport> {
self.map
.lock()
.get(&Self::key(share_id, relative_path, size))
.filter(|r| r.introspect_rev == INTROSPECT_REV)
.cloned()
}
pub fn put(&self, share_id: &str, relative_path: &str, size: u64, report: IntrospectionReport) {
let snapshot = {
let mut g = self.map.lock();
if g.len() >= MAX_ENTRIES {
g.clear();
}
g.insert(Self::key(share_id, relative_path, size), report);
g.clone()
};
// Persist outside the lock; tmp+rename so a crash mid-write
// leaves the previous cache intact.
let path = self.path.as_path();
let tmp = path.with_extension("json.tmp");
let Ok(body) = serde_json::to_vec_pretty(&snapshot) else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if std::fs::write(&tmp, body).is_ok() {
let _ = std::fs::rename(&tmp, path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::introspect::DistroFamily;
#[test]
fn round_trips_across_reopen_and_rev_gates() {
let dir = tempfile::tempdir().unwrap();
let cache = RemoteIntrospectCache::open(dir.path(), "t.json");
assert!(cache.get("s1", "a.iso", 100).is_none());
let fresh = IntrospectionReport {
family: DistroFamily::RhelFedora,
introspect_rev: INTROSPECT_REV,
el_torito: true,
..Default::default()
};
cache.put("s1", "a.iso", 100, fresh.clone());
assert_eq!(
cache.get("s1", "a.iso", 100).unwrap().family,
DistroFamily::RhelFedora
);
// Different size = different file = miss.
assert!(cache.get("s1", "a.iso", 101).is_none());
// Survives a reopen.
let cache2 = RemoteIntrospectCache::open(dir.path(), "t.json");
assert!(cache2.get("s1", "a.iso", 100).is_some());
// Stale-rev entries never hit.
let stale = IntrospectionReport {
family: DistroFamily::Arch,
introspect_rev: INTROSPECT_REV - 1,
..Default::default()
};
cache2.put("s1", "b.iso", 7, stale);
assert!(cache2.get("s1", "b.iso", 7).is_none());
}
#[test]
fn corrupt_cache_file_starts_empty() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("t.json"), b"{nope").unwrap();
let cache = RemoteIntrospectCache::open(dir.path(), "t.json");
assert!(cache.get("s", "x.iso", 1).is_none());
}
}
+12 -150
View File
@@ -56,9 +56,7 @@
//! hint}` error shape is shared so the storage tab renders all three
//! protocols through one code path.
use crate::introspect::{introspect_reader, provisional_report};
use crate::iso_fs::{self, FileLocation, IsoReadAt};
use crate::remote_cache::RemoteIntrospectCache;
use crate::introspect::{DistroFamily, IntrospectionReport};
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
use bytes::Bytes;
use openpxe_core::{Error, Result};
@@ -98,18 +96,6 @@ const READ_CHUNK_BYTES: usize = 64 * 1024;
/// body stream. 16 * 64 KiB ≈ 1 MiB max buffer per stream.
const STREAM_BUFFER_DEPTH: usize = 16;
/// v0.7.4: per-ISO budget for a background introspection probe — one SSH
/// connection plus a few dozen KiB-sized reads. SSH handshakes cost more
/// than NFS mounts, but 30s still only trips on a wedged server.
const INTROSPECT_TIMEOUT: Duration = Duration::from_secs(30);
/// One queued background-introspection unit (v0.7.4).
struct ProbeJob {
iso_id: String,
filename: String,
size: u64,
}
/// Which credential the share authenticates with. The secret itself
/// lives in the 0600 creds file, never here.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -223,9 +209,6 @@ pub struct SftpShareManager {
/// Serializes scan operations on the same manager for predictable
/// log output; each scan opens its own SSH connection.
op_lock: Arc<tokio::sync::Mutex<()>>,
/// v0.7.4: persisted introspection results keyed `share/path@size`,
/// so a restart re-probes only new or replaced ISOs.
introspect_cache: RemoteIntrospectCache,
}
impl SftpShareManager {
@@ -239,7 +222,6 @@ impl SftpShareManager {
inner: Arc::new(Mutex::new(Inner::default())),
iso_store,
op_lock: Arc::new(tokio::sync::Mutex::new(())),
introspect_cache: RemoteIntrospectCache::open(work_dir, "sftp_introspect_cache.json"),
}
}
@@ -492,25 +474,19 @@ impl SftpShareManager {
};
let mut count = 0u32;
let mut to_probe: Vec<ProbeJob> = Vec::new();
for entry in listing {
let iso_id = format!("sftp-{}-{}", share.id, slugify_str(&entry.filename));
// v0.7.4: real introspection over the share — SFTP file
// handles are seekable, so the ISO9660 probes work remotely.
// Cache hit → full report now; miss → provisional filename-
// based report (scan returns fast) + a queued background
// probe that upgrades the entry in place.
let cached = self
.introspect_cache
.get(&share.id, &entry.filename, entry.size);
let report = cached.unwrap_or_else(|| {
to_probe.push(ProbeJob {
iso_id: iso_id.clone(),
filename: entry.filename.clone(),
size: entry.size,
});
provisional_report(&entry.filename)
});
// Same as NFS/SMB: no over-the-network introspection yet, so
// register `Unknown` and let the boot-entry generator fall
// back to filename-based detection. SFTP *could* do bounded
// PVD reads (it has random access) a follow-up can add it.
let report = IntrospectionReport {
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 source = IsoSource::Sftp {
share_id: share.id.clone(),
@@ -536,88 +512,11 @@ impl SftpShareManager {
target: "openpxe::sftp",
id = %id, server = %share.server, export = %share.export,
iso_count = count,
pending_introspection = to_probe.len(),
"SFTP share scanned"
);
if !to_probe.is_empty() {
self.spawn_introspection_pass(&share, &creds, to_probe);
}
Ok(count)
}
/// v0.7.4: probe each queued ISO over its own SSH connection and swap
/// the full introspection into the store as results land. Detached so
/// neither startup nor the share-add API call waits on a big library;
/// per-ISO failures leave the provisional entry, which still sanboots.
fn spawn_introspection_pass(&self, share: &SftpShare, creds: &SftpCreds, work: Vec<ProbeJob>) {
let store = self.iso_store.clone();
let cache = self.introspect_cache.clone();
let share_id = share.id.clone();
let params = ConnParams::from_share(share);
let creds = creds.clone();
let queued = work.len();
tokio::spawn(async move {
let mut upgraded = 0usize;
for job in work {
let probe = async {
let mut reader = SftpReadAt::open(&params, &creds, &job.filename).await?;
let report =
introspect_reader(&mut reader, job.size, &job.filename, false).await;
Ok::<_, SftpClientError>(report)
};
match tokio::time::timeout(INTROSPECT_TIMEOUT, probe).await {
Ok(Ok(report)) => {
cache.put(&share_id, &job.filename, job.size, report.clone());
if store.update_external_introspection(&job.iso_id, report) {
upgraded += 1;
}
}
Ok(Err(e)) => tracing::warn!(
target: "openpxe::sftp",
share = %share_id, iso = %job.filename,
"introspection failed: {e}"
),
Err(_) => tracing::warn!(
target: "openpxe::sftp",
share = %share_id, iso = %job.filename,
"introspection timed out after {}s", INTROSPECT_TIMEOUT.as_secs()
),
}
}
tracing::info!(
target: "openpxe::sftp",
share = %share_id, queued, upgraded,
"remote introspection pass complete"
);
});
}
/// v0.7.4: locate `in_iso_path` inside a share-hosted ISO. Returns the
/// byte range so the HTTP layer can serve kernel/initrd files out of
/// remote ISOs with a follow-up ranged [`Self::stream_iso`].
pub async fn locate_in_iso(
&self,
share_id: &str,
filename: &str,
in_iso_path: &str,
) -> Result<Option<FileLocation>> {
let share = self
.get(share_id)
.ok_or_else(|| Error::Invalid(format!("no such SFTP share '{share_id}'")))?;
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return Err(Error::Invalid(format!("invalid filename '{filename}'")));
}
let creds = self
.read_creds(share_id)
.await
.map_err(|e| Error::Invalid(format!("could not read credentials: {e}")))?;
let params = ConnParams::from_share(&share);
let mut reader = SftpReadAt::open(&params, &creds, filename)
.await
.map_err(|e| Error::Invalid(format!("sftp open '{filename}': {e}")))?;
Ok(iso_fs::lookup(&mut reader, in_iso_path).await)
}
fn pin_fingerprint(&self, id: &str, fingerprint: String) {
if fingerprint.is_empty() {
return;
@@ -759,43 +658,6 @@ struct SftpConn {
sftp: SftpSession,
}
/// v0.7.4: random-access reader over one SSH connection + open file
/// handle — the [`IsoReadAt`] impl that lets the ISO9660 walker and
/// introspection probes run against share-hosted images. Holds the
/// `SftpConn` so the SSH session outlives every read.
struct SftpReadAt {
_conn: SftpConn,
file: russh_sftp::client::fs::File,
}
impl SftpReadAt {
async fn open(
p: &ConnParams,
creds: &SftpCreds,
filename: &str,
) -> std::result::Result<Self, SftpClientError> {
let (conn, _fp) = connect(p, creds).await?;
let full = format!("{}/{}", p.export.trim_end_matches('/'), filename);
let file = conn
.sftp
.open(full)
.await
.map_err(|e| SftpClientError::Sftp(e.to_string()))?;
Ok(Self { _conn: conn, file })
}
}
impl IsoReadAt for SftpReadAt {
async fn read_at(&mut self, offset: u64, len: u32) -> std::io::Result<Vec<u8>> {
self.file.seek(SeekFrom::Start(offset)).await?;
let mut buf = vec![0u8; len as usize];
// read_exact loops over the transport's short reads and fails
// with UnexpectedEof past end-of-file — exactly the contract.
self.file.read_exact(&mut buf).await?;
Ok(buf)
}
}
/// russh client handler implementing trust-on-first-use host-key
/// verification. We never construct an `Err` from `check_server_key`;
/// returning `Ok(false)` makes russh abort the handshake, and the
+39 -19
View File
@@ -56,7 +56,7 @@
//! streaming. A follow-up release can add libsmbclient-based seek if
//! a real workload needs it.
use crate::introspect::provisional_report;
use crate::introspect::{DistroFamily, IntrospectionReport};
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
use openpxe_core::{Error, Result};
use parking_lot::Mutex;
@@ -231,7 +231,10 @@ impl SmbShareManager {
/// Add or refresh a share. Validates the input, writes a creds
/// file, probes connectivity, and scans for ISOs.
pub async fn add(&self, req: SmbAddRequest) -> std::result::Result<SmbShare, SmbShareError> {
pub async fn add(
&self,
req: SmbAddRequest,
) -> std::result::Result<SmbShare, SmbShareError> {
let server = normalize_server(&req.server);
let share = req.share.trim().trim_start_matches('/').to_string();
if server.is_empty() {
@@ -306,9 +309,7 @@ impl SmbShareManager {
if let Err(e) = self.rescan_inner(&id).await {
let m = self.get(&id);
return Err(SmbShareError {
error: m
.as_ref()
.and_then(|m| m.last_error.clone())
error: m.as_ref().and_then(|m| m.last_error.clone())
.unwrap_or_else(|| e.to_string()),
stderr: String::new(),
hint: m.and_then(|m| m.last_hint),
@@ -364,7 +365,11 @@ impl SmbShareManager {
/// throttling concurrent smbclients) would need to await without
/// changing the call sites.
#[allow(clippy::unused_async)]
pub async fn stream_iso(&self, share_id: &str, filename: &str) -> Result<SmbStream> {
pub async fn stream_iso(
&self,
share_id: &str,
filename: &str,
) -> Result<SmbStream> {
let share = self
.get(share_id)
.ok_or_else(|| Error::Invalid(format!("no such SMB share '{share_id}'")))?;
@@ -372,7 +377,9 @@ impl SmbShareManager {
// share root. smbclient itself accepts only filenames at the
// share root in our `get` form, but belt-and-suspenders.
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return Err(Error::Invalid(format!("invalid filename '{filename}'")));
return Err(Error::Invalid(format!(
"invalid filename '{filename}'"
)));
}
let creds = share
.creds_path
@@ -455,14 +462,21 @@ impl SmbShareManager {
let mut count = 0u32;
for entry in listing {
let iso_id = format!("smb-{}-{}", share.id, slugify_str(&entry.filename));
// SMB sources can't get the content-probe pass NFS/SFTP got
// in v0.7.4 — the ISO9660 probes need seeks, and smbclient's
// CLI streaming doesn't seek. The provisional filename-token
// report is as far as SMB detection goes: family for the UI
// when the name says it ("rhel-9.0…", "Win11_…"), and
// `introspect_rev = 0` so the entry generator keeps the
// optimistic sanboot entry.
let report = provisional_report(&entry.filename);
// SMB sources don't get a real introspection pass — that
// would require seeking into the ISO9660 PVD over the
// network, and smbclient CLI doesn't seek. We register an
// `Unknown` family so the boot-entry generator falls back
// to generic sanboot/wimboot detection from the filename
// and the operator gets *something* bootable. A follow-up
// release can do a bounded `smbclient get` of the first
// 64 KiB for real detection.
let report = IntrospectionReport {
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 source = IsoSource::Smb {
share_id: share.id.clone(),
@@ -529,7 +543,10 @@ impl SmbShareManager {
} else {
String::new()
};
return Err((format!("could not exec smbclient: {e}"), stderr));
return Err((
format!("could not exec smbclient: {e}"),
stderr,
));
}
};
if !output.status.success() {
@@ -740,7 +757,10 @@ fn parse_ls_iso(out: &str) -> Vec<SmbListEntry> {
/// Pre-flight TCP probe to `server:port`. Format matches v0.4.64 NFS
/// probe so the UI banner reads consistently.
async fn tcp_probe(server: &str, port: u16) -> std::result::Result<(), (String, String)> {
async fn tcp_probe(
server: &str,
port: u16,
) -> std::result::Result<(), (String, String)> {
use tokio::net::TcpStream;
let addr = format!("{server}:{port}");
match tokio::time::timeout(PROBE_TIMEOUT, TcpStream::connect(&addr)).await {
@@ -917,8 +937,8 @@ mod tests {
// exec error in `error` plus an empty `stderr`. The
// SmbShareError constructor's hint_for fallback checks error
// too, so this pattern needs to translate as well.
let h2 =
hint_for("could not exec smbclient: No such file or directory (os error 2)").unwrap();
let h2 = hint_for("could not exec smbclient: No such file or directory (os error 2)")
.unwrap();
assert!(h2.contains("smbclient"));
}
+18 -145
View File
@@ -223,8 +223,7 @@ impl IsoStore {
continue;
}
if let Ok(text) = tokio::fs::read_to_string(&p).await {
if let Ok(mut meta) = serde_json::from_str::<IsoMeta>(&text) {
self.reintrospect_if_stale(&mut meta).await;
if let Ok(meta) = serde_json::from_str::<IsoMeta>(&text) {
self.insert(meta);
}
}
@@ -232,46 +231,6 @@ impl IsoStore {
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) {
self.inner.write().isos.insert(meta.id.clone(), meta);
}
@@ -342,18 +301,9 @@ impl IsoStore {
/// SMB sources or when the file is missing.
pub fn iso_path_for(&self, id: &str) -> Option<PathBuf> {
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 {
IsoSource::Local => {
let path = self.iso_path(&meta.id);
let path = self.iso_path(id);
if path.exists() {
Some(path)
} else {
@@ -415,28 +365,6 @@ impl IsoStore {
self.inner.write().isos.insert(id, meta);
}
/// v0.7.4: swap in a completed introspection for an external ISO and
/// regenerate its boot entries. Used by the NFS/SFTP managers'
/// background probe pass — the scan registers a provisional
/// (filename-only) report immediately so startup and share-add stay
/// fast, then this upgrades each entry as its probe finishes.
/// Operator-set fields (category, password) are preserved; returns
/// `false` when the id is gone (share removed or re-scanned away
/// mid-probe), which callers treat as a benign no-op.
pub fn update_external_introspection(
&self,
id: &str,
introspection: IntrospectionReport,
) -> bool {
let mut g = self.inner.write();
let Some(m) = g.isos.get_mut(id) else {
return false;
};
m.boot_entries = generate_boot_entries(&m.id, &m.filename, &introspection);
m.introspection = introspection;
true
}
/// Drop every entry that belongs to `share_id`. Used by the SMB
/// and NFS share managers when an operator removes a share, or
/// before re-scanning to clean out stale entries. The same id
@@ -670,33 +598,15 @@ fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> V
}]
}
_ => {
// No Windows-install media and no Linux kernel/initrd. Decide
// 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 {
id: format!("{id}-sanboot"),
title,
kind: BootKind::SanBootIso {
iso_url: format!("iso/{id}.iso"),
},
}]
} else {
Vec::new()
}
// Last-resort SAN boot. Won't work for large modern ISOs, but
// lets the ISO at least appear in the menu.
vec![BootEntry {
id: format!("{id}-sanboot"),
title: format!("{title} (SAN boot — may fail for >1GiB ISOs)"),
kind: BootKind::SanBootIso {
iso_url: format!("iso/{id}.iso"),
},
}]
}
}
}
@@ -770,49 +680,6 @@ mod tests {
assert!(!s.contains(" --- "), "stray ---: {s}");
}
#[test]
fn boot_entries_respect_el_torito_and_source() {
use crate::introspect::INTROSPECT_REV;
// ESXi / VMvisor installer shape: bootable (carries an El Torito
// catalog) but not classifiable as Windows or Linux. Must yield a
// single sanboot entry so it's selectable + boots via emulated CD.
let esxi = IntrospectionReport {
family: DistroFamily::Unknown,
volume_label: Some("ESXI-7.0U3".into()),
el_torito: true,
introspect_rev: INTROSPECT_REV,
..Default::default()
};
let e = generate_boot_entries("esxi", "VMware-VMvisor-Installer-7.0U3n.iso", &esxi);
assert_eq!(e.len(), 1, "ESXi should get exactly one boot entry");
assert!(matches!(e[0].kind, BootKind::SanBootIso { .. }));
// Clean title — no stale ">1GiB may fail" disclaimer.
assert!(!e[0].title.contains("may fail"), "title: {}", e[0].title);
// VCSA / data-appliance shape: locally introspected (rev set), no
// boot catalog, not Windows/Linux. Genuinely unbootable → no entry,
// so it stays out of the iPXE menu (the dashboard flags it instead).
let vcsa = IntrospectionReport {
family: DistroFamily::Unknown,
el_torito: false,
introspect_rev: INTROSPECT_REV,
..Default::default()
};
assert!(
generate_boot_entries("vcsa", "VMware-VCSA-all-8.0.iso", &vcsa).is_empty(),
"data/appliance ISO must produce no boot entry"
);
// Remote-share ISO: never introspected (rev 0, no random access over
// SMB/NFS/SFTP). Assume bootable and offer sanboot rather than hide a
// likely-bootable installer.
let remote = IntrospectionReport::default();
let r = generate_boot_entries("remote", "unknown-remote.iso", &remote);
assert_eq!(r.len(), 1, "remote (uninspected) ISO keeps a sanboot entry");
assert!(matches!(r[0].kind, BootKind::SanBootIso { .. }));
}
fn fake_meta(id: &str) -> IsoMeta {
IsoMeta {
id: id.into(),
@@ -820,7 +687,13 @@ mod tests {
size_bytes: 0,
sha256_hex: None,
uploaded_at: OffsetDateTime::now_utc(),
introspection: IntrospectionReport::default(),
introspection: IntrospectionReport {
family: DistroFamily::Unknown,
volume_label: None,
kernel_path: None,
initrd_paths: vec![],
has_boot_wim: false,
},
boot_entries: vec![],
source: IsoSource::Local,
password_hash: None,
-70
View File
@@ -184,10 +184,6 @@ async fn main() -> anyhow::Result<()> {
"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 {
iso_store: iso_store.clone(),
clients: clients.clone(),
@@ -195,10 +191,7 @@ async fn main() -> anyhow::Result<()> {
queue: queue.clone(),
hosts: hosts.clone(),
boot_log: boot_log.clone(),
boot_rules: boot_rules.clone(),
boot_tokens: openpxe_core::BootTokens::new(),
branding: branding.clone(),
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
admin: admin.clone(),
sessions: sessions.clone(),
sso: sso.clone(),
@@ -215,7 +208,6 @@ async fn main() -> anyhow::Result<()> {
started_at: time::OffsetDateTime::now_utc(),
public_base_url: public_base_url.clone(),
nic_name: net.nic_name,
nic_link: net.nic_link,
subnet_mask: net.subnet_mask,
gateway: net.gateway,
};
@@ -237,28 +229,11 @@ async fn main() -> anyhow::Result<()> {
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(
config.server.tftp_bind,
config.server.tftp_port,
clients.clone(),
metrics.clone(),
Some(tftp_dynamic),
);
let tftp_task = tokio::spawn(tftp.run());
@@ -272,10 +247,6 @@ async fn main() -> anyhow::Result<()> {
public_base_url.clone(),
clients.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())
}
@@ -449,12 +420,6 @@ struct NetworkInfo {
nic_name: String,
subnet_mask: 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
@@ -515,44 +480,9 @@ fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo {
}
}
info.nic_link = detect_link_info(&info.nic_name);
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 {
let prefix = prefix.min(32);
let mask: u32 = if prefix == 0 {
+1 -1
View File
@@ -14,4 +14,4 @@
pub mod server;
pub use server::{DynamicAsset, TftpServer};
pub use server::TftpServer;
+17 -53
View File
@@ -7,12 +7,12 @@
//! `tftpd`/`in.tftpd` works and is why TFTP is awkward behind stateful NAT:
//! the ephemeral ports must be reachable from the client.
//!
//! We only serve files from `openpxe_ipxe_assets::asset_slice` — that is,
//! We only serve files from `openpxe_ipxe_assets::asset_bytes` — that is,
//! the bundled iPXE binaries and wimboot. No filesystem is ever opened, so
//! `../` path traversal attempts simply return ENOENT.
use openpxe_core::{ClientEvent, ClientRegistry};
use openpxe_ipxe_assets::asset_slice;
use openpxe_ipxe_assets::asset_bytes;
use socket2::{Domain, Protocol, Socket, Type};
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
@@ -21,7 +21,6 @@ use tokio::net::UdpSocket;
// TFTP opcodes.
const OP_RRQ: u16 = 1;
const OP_WRQ: u16 = 2;
const OP_DATA: u16 = 3;
const OP_ACK: u16 = 4;
const OP_ERROR: u16 = 5;
@@ -32,19 +31,11 @@ const ERR_NOT_DEFINED: u16 = 0;
const ERR_FILE_NOT_FOUND: u16 = 1;
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 {
bind: IpAddr,
port: u16,
clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
dynamic: Option<DynamicAsset>,
}
impl TftpServer {
@@ -53,14 +44,12 @@ impl TftpServer {
port: u16,
clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
dynamic: Option<DynamicAsset>,
) -> Self {
Self {
bind,
port,
clients,
metrics,
dynamic,
}
}
@@ -82,11 +71,8 @@ impl TftpServer {
let clients = clients.clone();
let metrics = metrics.clone();
let bind_ip = self.bind;
let dynamic = self.dynamic.clone();
tokio::spawn(async move {
if let Err(e) =
handle_rrq(data, from, bind_ip, clients, metrics.clone(), dynamic).await
{
if let Err(e) = handle_rrq(data, from, bind_ip, clients, metrics.clone()).await {
metrics.record_tftp_err();
tracing::warn!(target: "openpxe::tftp", peer=%from, "handler error: {e}");
}
@@ -101,45 +87,18 @@ async fn handle_rrq(
bind_ip: IpAddr,
clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
dynamic: Option<DynamicAsset>,
) -> anyhow::Result<()> {
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(());
};
let Request {
filename,
mode,
options,
filename, options, ..
} = req;
// Per-transfer ephemeral socket.
let sock = bind_udp(bind_ip, 0)?;
// 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 Some(file_bytes) = asset_bytes(&filename) else {
let _ = send_error(&sock, peer, ERR_FILE_NOT_FOUND, "no such file").await;
tracing::info!(target: "openpxe::tftp", peer=%peer, file=%filename, "404");
clients.record(
@@ -303,6 +262,7 @@ async fn handle_rrq(
#[derive(Debug)]
struct Request {
filename: String,
#[allow(dead_code)]
mode: String,
options: Vec<(String, String)>,
}
@@ -433,13 +393,17 @@ fn bind_udp(bind: IpAddr, port: u16) -> anyhow::Result<UdpSocket> {
Ok(UdpSocket::from_std(std_sock)?)
}
/// Pure-logic mirror of `handle_rrq`'s windowing math, exercised by the
/// 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
/// edge cases (exact-blksize tail, short tail, single-block window,
/// block-number wraparound).
#[cfg(test)]
fn plan_window(
#[allow(dead_code)]
const _UNUSED: (u16, u16) = (ERR_NOT_DEFINED, ERR_ILLEGAL_OP);
/// Pure-logic helper used by the unit tests below and (in a refactor) by
/// `handle_rrq`. Given a position in the file and the window, return the
/// (block_no, chunk_len) list this window will emit. Useful as a sanity
/// check that our windowing math matches the wire behavior the spec
/// requires — tested against edge cases (exact-blksize tail, short tail,
/// single-block window).
#[must_use]
pub fn plan_window(
total: usize,
offset: usize,
blksize: usize,
-25
View File
@@ -912,28 +912,3 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px;
}
.modal-actions .submit { width: auto; padding: 8px 18px; }
/* v0.7.2: a label.field directly followed by the card's action button
stacked its own 14px bottom margin onto the button's 16px top margin
(30px total) visible on Queue "Launch for all waiting" and the
Network "Save". Collapse the doubled gap so every primary action sits
the same 16px below its form. */
.card .body > label.field:has(+ button) { margin-bottom: 0; }
/* v0.7.2: inline list filter above a table (Available images). The input
is wrapped in a label.field so it borrows the standard text-field chrome
and matches every other input in the app; this wrapper just insets it
from the card edges so it lines up with the header text above. */
.list-search { padding: 14px 16px; }
/* v0.7.4: pager footer under the Available-images table quiet status
text on the left, ghost Prev/Next on the right. */
.list-pager {
display: flex; align-items: center; gap: 8px;
padding: 12px 16px;
color: var(--fg-dim); font-size: 12px;
font-variant-numeric: tabular-nums;
}
.list-pager .spacer { flex: 1; }
.list-pager button { padding: 4px 12px; font-size: 12px; }
.list-pager button:disabled { opacity: 0.45; cursor: default; }
+60 -264
View File
@@ -165,32 +165,17 @@
if (fam === 'windows_pe') {
return { ok: true };
}
// Linux with a detected kernel/initrd — direct kernel+initrd boot.
if (iso.introspection.kernel_path) {
return { ok: true };
if (!iso.introspection.kernel_path) {
// Not Windows and no Linux kernel/initrd detected. Small images can
// still try the sanboot fallback; large ones almost certainly aren't
// network-bootable installers (e.g. appliance bundles like VMware
// VCSA) — flag them clearly instead of with a Linux-centric message.
if (iso.size_bytes > 1.5 * 1024 * 1024 * 1024) {
return { ok: false, reason: "not a recognized network-bootable installer (no Windows or Linux boot files found) — this image can't be PXE-booted" };
}
return { ok: true, warn: 'no kernel detected — sanboot fallback may not work' };
}
// 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)' };
}
// v0.7.4: NFS/SFTP ISOs now introspect over the share, so a probed
// remote ISO flows through the kernel/el_torito branches above like
// a local one. introspect_rev 0 means the probe hasn't landed yet
// (it runs in the background right after a scan) or never can (SMB —
// smbclient can't seek): stay optimistic and let sanboot try.
const remote = iso.source && iso.source.kind && iso.source.kind !== 'local';
if (remote && (iso.introspection.introspect_rev || 0) === 0) {
return { ok: true, warn: 'remote ISO — awaiting introspection; 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 cant be PXE-booted' };
return { ok: true };
}
// v0.5.2: pretty label for an unattended file's detected kind.
@@ -201,51 +186,6 @@
})[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
// hostname, auto IP, and an unattended-file picker — reused by the
// Hosts pin form and the Queue "Profile" modal. `files` is the
@@ -269,7 +209,7 @@
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Auto IP address (optional)'), ipInput]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Unattended file (in Storage → Advanced)'), sel]),
el('span', {class:'name'}, 'Unattended file'), sel]),
]);
return {
wrap,
@@ -351,23 +291,14 @@
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
el('div', {class: 'label'}, 'Images available'),
el('div', {class: 'value'}, String(isos.length)),
el('div', {class: 'trend'}, (() => {
// v0.5.9: count families honestly. Anything that isn't a known
// Linux family or Windows lands in "other" (data/appliance ISOs
// like VMware VCSA, or as-yet-unclassified images) instead of
// being lumped under "Linux".
const LINUX = ['debian_ubuntu', 'rhel_fedora', 'opensuse', 'arch', 'alpine'];
const win = isos.filter(i => i.introspection.family === 'windows_pe').length;
const lin = isos.filter(i => LINUX.includes(i.introspection.family)).length;
const other = isos.length - win - lin;
el('div', {class: 'trend'},
isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' +
isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux · ' +
// 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(' · ');
})()),
((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0)) +
' remote share' +
(((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0)) === 1 ? '' : 's')),
])),
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
el('div', {class: 'label'}, 'Uptime'),
@@ -412,7 +343,7 @@
const settings = status.settings;
const problems = isos.map(i => ({i, b: bootability(i, settings)})).filter(x => !x.b.ok);
const problemsBlock = problems.length ? el('div', {class:'card'}, [
el('header', {}, [el('h2', {}, 'Non-bootable images')]),
el('header', {}, [el('h2', {}, 'Images that won\'t boot with current settings')]),
el('div', {class:'body'},
problems.map(({i, b}) => el('div', {class:'row-warn'},
'⚠ ' + i.filename + ' — ' + b.reason)))
@@ -447,12 +378,6 @@
el('div', {class:'v'}, net.gateway || '?'),
el('div', {class:'k'}, 'Public base URL'),
el('div', {class:'v'}, net.public_base_url),
// 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. Kept last — the joined
// value runs long, so it wraps cleanly at the bottom of the list.
el('div', {class:'k'}, 'Link'),
el('div', {class:'v'}, net.nic_link || '—'),
]),
el('p', {class:'msg'},
'Server IP, NIC, mask, and gateway are auto-detected at startup. ' +
@@ -842,12 +767,6 @@
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'}, [
el('td', {}, [
el('div', {style:'display:flex;align-items:center;gap:8px'}, [
@@ -892,42 +811,8 @@
}}, 'Remove'),
]),
]);
tr.dataset.search = searchText;
rowsAndEditors.push(tr, editorRow);
});
// v0.7.2: client-side filter over the image table; v0.7.4: paged
// 5 at a time so a 50-image library doesn't become a scroll wall.
// Rows travel in (row, password-editor) pairs. One view function
// applies filter-then-page; editors close on any view change.
const ISO_PAGE_SIZE = 5;
let isoPage = 0;
const pagerInfo = el('span', {});
const prevBtn = el('button', {class:'ghost', onclick: () => { isoPage -= 1; applyIsoListView(); }}, ' Prev');
const nextBtn = el('button', {class:'ghost', onclick: () => { isoPage += 1; applyIsoListView(); }}, 'Next ');
function applyIsoListView() {
const q = isoSearch.value.trim().toLowerCase();
const visible = [];
for (let k = 0; k + 1 < rowsAndEditors.length; k += 2) {
const row = rowsAndEditors[k];
rowsAndEditors[k + 1].style.display = 'none';
row.style.display = 'none';
if (!q || (row.dataset.search || '').includes(q)) visible.push(row);
}
const pages = Math.max(1, Math.ceil(visible.length / ISO_PAGE_SIZE));
if (isoPage >= pages) isoPage = pages - 1;
if (isoPage < 0) isoPage = 0;
visible.slice(isoPage * ISO_PAGE_SIZE, (isoPage + 1) * ISO_PAGE_SIZE)
.forEach(r => { r.style.display = ''; });
pagerInfo.textContent = visible.length
? 'Showing ' + (isoPage * ISO_PAGE_SIZE + 1) + '' +
Math.min(visible.length, (isoPage + 1) * ISO_PAGE_SIZE) + ' of ' + visible.length
: 'No images match';
prevBtn.disabled = isoPage === 0;
nextBtn.disabled = isoPage >= pages - 1;
}
const isoSearch = el('input', {type:'search', placeholder:'Filter images by name, type, or source',
spellcheck:'false', oninput: () => { isoPage = 0; applyIsoListView(); }});
const isoTable = isos.length
? el('table', {}, [
el('thead', {}, el('tr', {}, [
@@ -939,13 +824,6 @@
el('tbody', {}, rowsAndEditors),
])
: el('div', {class:'empty'}, 'No images yet. Upload an ISO or add an SMB share.');
// v0.7.4: pager footer, shown once the library outgrows one page.
const isoPager = isos.length > ISO_PAGE_SIZE
? el('div', {class:'list-pager'}, [
pagerInfo, el('span', {class:'spacer'}), prevBtn, nextBtn,
])
: null;
if (isos.length) applyIsoListView();
// ── Remote shares section (v0.5.1) ──
// SMB + NFS unified into one "Remote shares" card with a protocol
@@ -1272,10 +1150,7 @@
unattFile.onchange = () => { if (unattFile.files[0]) uploadUnattended(unattFile.files[0]); };
const unattRows = unattendedFiles.length
? unattendedFiles.map(f => el('div', {
class:'nfs-row',
'data-search': (f.filename + ' ' + unattendedKindLabel(f.kind) + ' ' + f.id).toLowerCase(),
}, [
? unattendedFiles.map(f => el('div', {class:'nfs-row'}, [
el('span', {class:'dot ok'}),
el('div', {}, [
el('div', {class:'id'}, [
@@ -1303,17 +1178,6 @@
]),
el('div', {class:'body'}, [
unattDrop, unattFile, unattMsg,
// v0.7.2: filter for big answer-file libraries.
unattendedFiles.length > 1 ? (() => {
const search = el('input', {type:'search', placeholder:'Filter files by name or kind',
spellcheck:'false', oninput: () => {
const q = search.value.trim().toLowerCase();
unattRows.forEach(r => {
r.style.display = (!q || (r.dataset.search || '').includes(q)) ? '' : 'none';
});
}});
return el('label', {class:'field', style:'margin-top:14px;margin-bottom:0'}, search);
})() : null,
el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows),
el('p', {class:'msg', style:'margin-top:14px'},
'These answer files drive unattended installs. Attach one to a ' +
@@ -1360,21 +1224,16 @@
el('h2', {}, 'Available images'),
el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')),
]),
isos.length > 1
? el('div', {class:'list-search'}, el('label', {class:'field', style:'margin-bottom:0'}, isoSearch))
: null,
isoTable,
isoPager,
]),
]), unattendedAdvanced]);
},
hosts: async () => {
const [{ hosts = [] }, isos, bootLogRes, unattRes, rulesCfg] = await Promise.all([
const [{ hosts = [] }, isos, bootLogRes, unattRes] = await Promise.all([
getJSON('/api/hosts'), getJSON('/api/isos'),
getJSON('/api/boot-log').catch(() => ({ events: [] })),
getJSON('/api/unattended').catch(() => ({ files: [] })),
getJSON('/api/boot-rules').catch(() => ({ rules: [], webhook_url: '' })),
]);
const bootEvents = bootLogRes.events || [];
const unattendedFiles = unattRes.files || [];
@@ -1389,22 +1248,8 @@
{id: '_tools_menu', title: '↳ Tools menu (built-in)'},
];
const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff or aa:bb:cc', spellcheck:'false'});
const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff', spellcheck:'false'});
const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'});
// 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', {},
[el('option', {value:''}, '— choose a target —')]
.concat(reserved.map(t => el('option', {value: t.id}, t.title)))
@@ -1417,40 +1262,21 @@
// hostname/IP templated into the served answer file.
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 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;
}
const r = await postJSON('/api/hosts', Object.assign({
mac, target: targetSel.value, label: labelInput.value,
}, profileFields.read()));
if (r.ok) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; render('hosts'); }
else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
return;
if (!macInput.value || !targetSel.value) {
msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
}
// 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 r = await postJSON('/api/hosts', Object.assign({
mac: macInput.value, target: targetSel.value, label: labelInput.value,
}, profileFields.read()));
if (r.ok) {
msg.textContent = 'Saved.'; msg.className = 'msg ok';
render('hosts');
} else {
const t = await r.text();
msg.textContent = 'Save failed: ' + t; msg.className = 'msg err';
}
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');
}}, 'Bind MAC to target');
const rows = hosts.map(h => {
// v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole
@@ -1507,32 +1333,23 @@
el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Pin MAC to boot target')),
el('div', {class:'body'}, [
// v0.7.3: MAC · Label · Architecture · Boot binary share one
// 4-up row so the controls line up across the page; the
// per-field guidance that used to sit under them moved into the
// note below to keep the inputs flush. Target spans full width
// on its own line beneath them.
el('div', {class:'form-row'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'MAC address or prefix'), macInput]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'MAC address'), macInput]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Architecture (optional)'), archSel]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Boot binary (optional)'), binSel]),
]),
el('label', {class:'field', style:'margin-top:14px'}, [
el('span', {class:'name'}, 'Target'),
targetSel,
el('label', {class:'field', style:'grid-column:1 / -1'}, [
el('span', {class:'name'}, 'Target'),
targetSel,
el('span', {class:'hint'},
'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'),
]),
]),
el('div', {style:'margin-top:16px'}, profileFields.wrap),
upsertBtn, msg,
el('p', {class:'msg', style:'margin-top:14px'},
'A full MAC pins one machine; a MAC prefix (OUI) or an architecture ' +
'saves a first-match group rule. Pin the boot binary to “shim” for ' +
'Secure Boot racks — zero failed boot cycles. When a matching client ' +
'requests boot.ipxe, OpenPXE short-circuits past the interactive menu ' +
'and chains directly; decision order is exact MAC pin → first matching ' +
'group rule → menu. 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.'),
'When a client with a bound MAC requests boot.ipxe, OpenPXE ' +
'short-circuits past the interactive menu and chains directly. ' +
'If an unattended file is selected, the matching kernel argument ' +
'is injected and the hostname/IP are templated into the answer file.'),
]),
]),
el('div', {class:'card'}, [
@@ -1542,7 +1359,6 @@
]),
table,
]),
groupRulesCard(rulesCfg, reserved.concat(targets)),
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Host log'),
@@ -2219,24 +2035,9 @@
el('div', {class:'about-hero'}, [
el('h2', {}, 'OpenPXE'),
el('p', {class:'lead'},
'The network-boot platform for modern infrastructure. Drop in an ISO ' +
'and every machine on your network — BIOS, UEFI, Secure Boot — can ' +
'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),
]))),
'Air-gapped network PXE boot, container-native, that anyone can run. ' +
'No CDN calls, no telemetry, no surprise external dependencies — ship ' +
'the image once, run it forever.'),
el('div', {class:'who'}, [
el('span', {}, 'Developer: '), el('strong', {}, 'Miles Ward'), el('br'),
el('span', {}, 'Version: '), el('strong', {}, status.version || '?'), el('br'),
@@ -2247,16 +2048,15 @@
]),
el('div', {style:'margin-top:18px'}, [updBtn, updMsg]),
el('p', {class:'msg', style:'margin-top:18px'},
'Private by design: no telemetry, no CDN calls, no runtime ' +
'dependencies on the outside world. Air-gapped labs, customer sites ' +
'without internet, and locked-down OpenShift clusters run the same ' +
'image, the same way, indefinitely.'),
'iPXE is an internal implementation detail. Everything the firmware ' +
'executes is generated from the settings on these tabs — there is no ' +
'hand-written .ipxe path anywhere in this product.'),
el('p', {class:'msg'},
'Principled by default: OpenPXE never asks an operator to install ' +
'test-signed drivers, modify a clients trust store, or weaken ' +
'Secure Boot. Everything the firmware executes is generated from the ' +
'settings on these tabs — there are no hand-written boot scripts to ' +
'maintain and no internals to learn.'),
'Vision: a deployment-grade tool that works on first try in the most ' +
'awkward environments — air-gapped labs, customer sites without ' +
'internet, OpenShift clusters with strict SCCs — without ever asking ' +
'an operator to install drivers signed with test certificates or to ' +
'flip "testsigning" on a target machine.'),
]),
]);
@@ -2480,9 +2280,7 @@
// own self-contained <form>; when SSO is enabled, a distinct
// "Sign in with …" button sits below a divider — the credential
// fields no longer double as the SSO trigger.
// `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 ssoLive = ssoConfig && ssoConfig.enabled && (ssoConfig.metadata_url || ssoConfig.metadata);
const ssoBlock = ssoLive
? el('div', {class:'sso-block'}, [
el('div', {class:'auth-divider'}, el('span', {}, 'or')),
@@ -2727,12 +2525,10 @@
])));
return;
}
// v0.5.9: the login card's "Sign in with …" button keys off the SSO
// descriptor that /api/me now carries (public, non-sensitive: enabled
// + idp_name + idp_logo_url). It's available signed in or out, so the
// 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;
// Preload the SSO config so the login card can offer the operator
// an "Sign in with X" button when configured. Failure is harmless.
try { ssoConfig = await fetch('/api/sso').then(r => r.ok ? r.json() : null); }
catch { ssoConfig = null; }
if (me.setup_required) {
showAuthScreen('setup');
+1 -9
View File
@@ -23,19 +23,11 @@ ARG RUST_VERSION=1.95
# and serve as the baseline that the PNG-enabled x86_64/arm64 UEFI
# binaries from the `ipxe-build` stage overlay on top of.
FROM debian:12-slim AS fetch
# 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 \
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
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
# Signed shim+GRUB (Secure Boot escalation rung). Redistributed
# unmodified from the official Fedora packages — see fetch-shim.sh for
# the trust model.
RUN bash scripts/fetch-shim.sh /src/assets/ipxe
########## build PNG-enabled iPXE from source ##########
# v0.4.69: THE graphical-boot-menu unlock. iVentoy paints a PNG
+7 -23
View File
@@ -41,31 +41,15 @@ DEST="${1:-$ROOT/assets/ipxe}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
# Pinned upstream iPXE. Rolling master is fine functionally, but a pin keeps
# builds reproducible, protects against a transient master breakage, and —
# crucially for the Docker image — busting this value invalidates the cached
# ipxe-build layer so an "update iPXE" release actually recompiles from the
# new upstream. Bump deliberately to a recent master commit.
#
# v0.6.1: ipxe/ipxe master @ 2026-06-09 (newer NIC drivers + EFI fixes;
# mirrors iVentoy 1.0.35 "Update iPXE").
# Pinned upstream iPXE. Rolling master is fine functionally, but a pin
# keeps builds reproducible and protects against a transient master
# breakage. Bump deliberately.
IPXE_REPO="https://github.com/ipxe/ipxe.git"
IPXE_REF="${IPXE_REF:-95ffbf4745553e8a207922389929e1943c0237c0}"
IPXE_REF="${IPXE_REF:-master}"
echo ">> fetching iPXE ($IPXE_REF)"
# Shallow-fetch the exact ref: works for a full commit SHA (GitHub allows
# reachable-SHA1-in-want) and for branch/tag names. Fall back to a full
# clone + checkout if the server refuses a direct fetch of this ref.
git init -q "$WORK/ipxe"
git -C "$WORK/ipxe" remote add origin "$IPXE_REPO"
if git -C "$WORK/ipxe" fetch -q --depth 1 origin "$IPXE_REF"; then
git -C "$WORK/ipxe" checkout -q FETCH_HEAD
else
echo " direct fetch failed; falling back to full clone + checkout"
rm -rf "$WORK/ipxe"
git clone -q "$IPXE_REPO" "$WORK/ipxe"
git -C "$WORK/ipxe" checkout -q "$IPXE_REF"
fi
echo ">> cloning iPXE ($IPXE_REF)"
git clone --depth 1 --branch "$IPXE_REF" "$IPXE_REPO" "$WORK/ipxe" 2>/dev/null \
|| git clone "$IPXE_REPO" "$WORK/ipxe"
SRC="$WORK/ipxe/src"
echo ">> applying OpenPXE config overrides (PNG + framebuffer + console cmd)"
+1 -9
View File
@@ -29,19 +29,11 @@ mkdir -p "$DEST"
# Upstream uses arch-scoped subdirectories; we flatten to the names our
# ClientArch::ipxe_bootfile() expects.
declare -a MAP=(
# DriverMode::Firmware (default) — reuse the firmware UNDI/SNP NIC stack.
"undionly.kpxe=undionly.kpxe"
"snponly.efi=x86_64-efi/snponly.efi"
"snponly-i386.efi=i386-efi/snponly.efi"
"snponly-arm64.efi=arm64-efi/snponly.efi"
# 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"
"ipxe.efi=x86_64-efi/ipxe.efi" # fallback with bundled drivers
)
BASE="https://boot.ipxe.org"
-96
View File
@@ -1,96 +0,0 @@
#!/usr/bin/env bash
# Fetch Fedora's Microsoft-signed Secure Boot chain — shim + GRUB — and
# place the EFI binaries under assets/ipxe/ with the filenames OpenPXE's
# DriverMode::Shim mapping expects:
#
# shimx64.efi x86_64: Microsoft-signed shim (first stage)
# grubx64.efi x86_64: Fedora-signed GRUB (loaded by shim, fetches
# the server-rendered grub.cfg over TFTP/HTTP)
# shimaa64.efi arm64 equivalents (best-effort — see below)
# grubaa64.efi
#
# Why Fedora: a supply-chain decision made deliberately (v0.7.0) — one
# vendor, fast security turnaround, and the same chain most netboot
# projects redistribute. The binaries are extracted from the official
# distro RPMs and shipped BYTE-FOR-BYTE UNMODIFIED; their signatures are
# what make the chain work, and modifying them would break it. This is
# the standard documented netboot path for Secure Boot (Red Hat
# Satellite, SUSE HTTPBoot) and involves no test certificates and no
# client trust-store changes.
#
# Trust model matches fetch-ipxe.sh: HTTPS to the official distribution
# point, no sha pinning because we track the latest signed build (which
# rotates on SBAT revocations — pinning would mean shipping revoked
# shims). Mirror to your own artifact store for deterministic builds.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DEST="${1:-$ROOT/assets/ipxe}"
mkdir -p "$DEST"
FEDORA_RELEASE="${FEDORA_RELEASE:-43}"
BASE="${FEDORA_MIRROR:-https://dl.fedoraproject.org/pub/fedora/linux/releases/$FEDORA_RELEASE/Everything}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
# Find the newest RPM in a repo directory whose name starts with
# `$pattern` followed by a version digit (anchoring on the digit keeps
# `grub2-efi-x64` from matching `grub2-efi-x64-cdboot`).
latest_rpm() {
local dir_url="$1" pattern="$2"
curl -fsSL "$dir_url/" \
| grep -oE "href=\"${pattern}-[0-9][^\"]*\.rpm\"" \
| sed 's/^href="//; s/"$//' \
| sort -V | tail -1
}
# fetch_chain <repo-arch> <shim-pkg> <grub-pkg> <shim-out> <grub-out> <hard|soft>
fetch_chain() {
local arch="$1" shim_pkg="$2" grub_pkg="$3" shim_out="$4" grub_out="$5" mode="$6"
local pkg_base="$BASE/$arch/os/Packages"
local sdir="$pkg_base/${shim_pkg:0:1}" gdir="$pkg_base/${grub_pkg:0:1}"
local shim_rpm grub_rpm
shim_rpm="$(latest_rpm "$sdir" "$shim_pkg" || true)"
grub_rpm="$(latest_rpm "$gdir" "$grub_pkg" || true)"
if [ -z "$shim_rpm" ] || [ -z "$grub_rpm" ]; then
echo "!! could not locate $shim_pkg/$grub_pkg RPMs under $pkg_base"
[ "$mode" = "hard" ] && exit 2
echo " skipping $arch Secure Boot chain (best-effort)"
return 0
fi
echo ">> $arch: $shim_rpm + $grub_rpm"
local exdir="$WORK/$arch"
mkdir -p "$exdir"
curl -fsSL -o "$exdir/shim.rpm" "$sdir/$shim_rpm"
curl -fsSL -o "$exdir/grub.rpm" "$gdir/$grub_rpm"
( cd "$exdir" \
&& rpm2cpio shim.rpm | cpio -idm --quiet "./boot/efi/EFI/*/$shim_out" \
&& rpm2cpio grub.rpm | cpio -idm --quiet "./boot/efi/EFI/*/$grub_out" )
local shim_path grub_path
shim_path="$(find "$exdir/boot" -name "$shim_out" | head -1)"
grub_path="$(find "$exdir/boot" -name "$grub_out" | head -1)"
if [ -z "$shim_path" ] || [ -z "$grub_path" ]; then
echo "!! RPM layout changed — $shim_out/$grub_out not found inside the packages"
[ "$mode" = "hard" ] && exit 2
return 0
fi
cp "$shim_path" "$DEST/$shim_out"
cp "$grub_path" "$DEST/$grub_out"
echo " installed $shim_out + $grub_out"
}
# x86_64 is the headline Secure Boot audience — fail the build if it
# can't be assembled so a regression is loud, not silent.
fetch_chain x86_64 shim-x64 grub2-efi-x64 shimx64.efi grubx64.efi hard
# arm64 is best-effort: skipping just means no Shim escalation rung for
# that arch (logged at startup by ipxe-assets::log_availability).
fetch_chain aarch64 shim-aa64 grub2-efi-aa64 shimaa64.efi grubaa64.efi soft
echo
echo "Secure Boot chain assets now in $DEST:"
ls -lh "$DEST"/shim*.efi "$DEST"/grub*.efi 2>/dev/null || true