Compare commits

...
19 Commits
Author SHA1 Message Date
Miles WardandClaude Opus 4.8 a71057fce6 v0.7.2: UI polish — list search, unified Hosts form, NIC link details, About refresh, spacing
Storage:
- Filter inputs for the Available images table (matches filename,
  detected family, category, source) and the Unattended files list
  (name, kind). Pure client-side; shown when there's more than one
  entry. Forty-image libraries are now navigable.

Hosts — one form, one mental model:
- The separate Boot rules card is gone. The Pin form gains
  'Architecture (optional)' next to Label (plus the v0.7.1 boot-binary
  pin): a full MAC with no architecture saves a per-host pin exactly as
  before; a MAC prefix and/or architecture saves a first-match-wins
  group rule. Saved rules render as a compact read-only 'Group rules'
  card with remove buttons.
- The boot-decision webhook keeps working via /api/boot-rules but no
  longer has a UI knob (operator feedback: not needed in the UI).
- Per-machine auto-deploy fields are rejected on group rules with a
  clear message (they're per-host values).

Network:
- New 'Link' row under NIC name: operstate · speed · duplex · port MAC,
  read from sysfs at startup (detect_link_info). Empty-degrades on
  non-Linux dev builds and virtual NICs. Confirms WHICH physical port
  answers PXE in multi-NIC/trunked environments.

About:
- Hero copy rewritten: positioning lead, three-pillar feature grid
  (Boot anything / Adapt to every machine / Run it in production), and
  the privacy + no-test-cert principles restated crisply.

Spacing:
- label.field:has(+ button) collapse fixes the doubled 30px gap above
  Queue 'Launch for all waiting' and Network 'Save' (now the same 16px
  as every other card action).

Validation: clippy clean, fmt clean, 299 workspace tests green, webui
syntax-checked. No protocol or boot-path changes in this release.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-09 21:47:48 -04:00
Miles WardandClaude Opus 4.8 29040e8a5a v0.7.1: walk the ladder once ever — persistent learned modes, rule pins, same-boot iPXE recovery
Answers the operational question 'can a machine try all three boot
binaries in one go?' The protocol can't carry three NBPs in one cycle
(one boot file per DHCP round, the Secure-Boot refusal happens after
handoff with no error report, and the broken-NIC case specifically needs
the firmware itself to load builtin-driver iPXE — GRUB's network rides
the same broken firmware stack). What we CAN do is make the walk a
once-per-machine-ever event and give operators a way to skip it:

- Learned driver modes persist (<work_dir>/driver_modes.json). A MAC
  that reaches the Shim rung, or confirms an iPXE handoff at Builtin,
  is pinned to disk: immune to the 30-min TTL, reloaded at startup.
  The file only carries exceptions — a healthy fleet never writes it.
  Corrupt file starts empty (standard crash-cache policy).
- Boot rules gain an optional driver_mode pin (auto/firmware/builtin/
  shim), consulted by the DHCP proxy BEFORE the escalation ladder:
  'this OUI is a Secure Boot rack -> serve shim immediately' = zero
  failed cycles. Mode-only rules coexist with target rules (a pin
  doesn't shadow a later target match). Editor column on Hosts tab.
- grub.cfg now tries to chainload all-drivers iPXE before showing the
  signed menu: with SB off the chainload succeeds and the client gets
  the full iPXE feature set back in the SAME boot (self-healing for
  mis-escalations, and the handoff then pins the working mode); with
  SB on, shim's verifier refuses it inline — no reboot — and the
  signed menu appears.

DhcpProxyServer now takes the escalation table + rules store from main
(persistence path comes from the configured work dir).

Validation: clippy clean, fmt clean, 299 workspace tests green (+9:
persistence round-trip across restart, Shim pin survives TTL, learned
Builtin survives TTL, corrupt-file recovery, default-mode-never-
persisted, rule-pin matching incl. unknown-mode tolerance and
pin/target coexistence, GRUB chainload-before-menu ordering, API
round-trip of the driver_mode field).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-09 21:16:22 -04:00
Miles WardandClaude Opus 4.8 3a32d65fb7 v0.7.0: Secure Boot chain, boot rules + decision webhook, tokenized answer files
Three features, all zero-toggle and principle-clean (single static musl
binary, container-first, no test certs, no client trust-store changes).

Secure Boot via signed shim+GRUB (automatic):
- The v0.6.1 escalation ladder gains a third rung: Firmware -> Builtin
  -> Shim. Secure-Boot firmware downloads our unsigned iPXE but refuses
  to execute it — indistinguishable from a failed chainload — so after
  two unconfirmed attempts the MAC is offered Fedora's Microsoft-signed
  shimx64.efi, which loads the signed GRUB, which fetches a
  server-rendered grub.cfg. Fully signed chain, SB stays on.
- scripts/fetch-shim.sh pulls shim-x64/grub2-efi-x64 (+aa64 best-effort)
  from the official Fedora 43 packages and ships the EFI binaries
  byte-for-byte unmodified; Dockerfile fetch stage gained rpm2cpio/cpio.
- New grub_script renderer (Linux kernel entries only — signed GRUB only
  boots signed kernels; sanboot/wimboot have no signed equivalent and
  are omitted with an explanatory menu line).
- TFTP server gains a DynamicAsset hook for server-rendered names
  (grub.cfg); HTTP serves the same config under /ipxe/grub.cfg for
  native UEFI HTTP Boot chains. Arch-aware fallback walks back down the
  ladder where no shim exists (BIOS, IA32).

Boot rules + decision webhook (open 'Matrix Boot'):
- Ordered first-match-wins rules over MAC prefix + client arch (the DHCP
  proxy now bakes arch into the boot.ipxe chain URL), generalizing
  per-MAC pins. Persisted to boot_rules.json; GET/PUT /api/boot-rules;
  rules editor + webhook field on the Hosts tab.
- Optional pixiecore-style webhook: unmatched boots GET
  <url>?mac=&arch= and 200 {"target":"id"} chains to it. Fail-open
  with a 2s budget — a dead endpoint can never block PXE.
- Decision order: exact pin -> rules -> webhook -> menu. Empty config
  is byte-for-byte the previous behavior.

Tokenized answer files (the post-WDS/CVE-2026-0386 hardening):
- Every generated unattended URL (inst.ks / preseed url / autoinstall
  seed) now carries a 4h boot-scoped token; /unattended/{id} and the
  cloud-init seed routes require it (or an operator session) once an
  admin exists. Stops answer-file credential harvesting by anything
  else on the network. No toggle; setup-mode installs stay open.

Validation: clippy clean, fmt clean, 290 workspace tests green
(+18 new across boot_tokens, boot_rules, arch ladder, escalation,
grub renderer, and four new full-flow integration tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-09 20:17:18 -04:00
Miles WardandClaude Opus 4.8 7f25bb681c v0.6.3: russh 0.61 security bump (CVE batch) + bergshamra 0.5 + axum 0.8
Security-driven dependency release.

- russh =0.55.0 (pinned) -> 0.61.2: closes the advisory batch reachable
  from our SFTP *client* path — unbounded/allocation-first packet
  parsing (CVE-2026-48110, CVE-2026-46702, CVE-2026-46673, HIGH) plus
  CVE-2026-48107 in client auth. A malicious or compromised SFTP server
  an operator pointed us at could previously OOM the PXE server. Also
  drops mlock on non-secret buffers (~21% SSH throughput upstream) —
  directly in the remote-share ISO streaming path. ring backend kept;
  zero code changes needed in sftp_share.rs.
- bergshamra 0.4 -> 0.5.1: the pin's blocking condition (stable
  RustCrypto generation, pkcs8 0.11) is now met upstream, so the
  =0.55.0 pin is deleted and its comment rewritten as history. 0.5 is
  secure-by-default for DSig (flags we already set explicitly) and
  fixes an XML-Enc DerivedKey fallthrough.
- axum 0.7 -> 0.8.9: route captures /:id -> {id} across the router and
  the /api/docs listing; ConnectInfo optional extraction moves to the
  Result form. Gains the HEAD content-length fix (iPXE/sanboot clients
  probe with HEAD before Range requests) and puts us back on the
  maintained line.

Validation: clippy clean, fmt clean, all 272 workspace tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-09 19:47:13 -04:00
Miles WardandClaude Opus 4.8 5da05a519d v0.6.2: Mythos Validation — full-codebase polish, hot-path optimizations, dhcproto 0.15
Codebase-wide review pass: finish or remove every loose end, take the
safe performance wins on the serving hot paths, and refresh the
dependency tree for reliability. No behavior changes for working
clients; legacy clients get clearer protocol errors.

Finalize / cleanup:
- Remove mac_allowlist/subnet_allowlist config fields — parsed but never
  enforced since introduction; the operator wants line-of-sight serving,
  so the honest fix is deletion, not wiring.
- Remove dead ClientRegistry API (get, set_selected_target,
  always-None selected_target field, never-emitted DhcpRequest/
  HttpIsoAsset events).
- TFTP: reject WRQ with ERR_ILLEGAL_OP and non-octet modes with a clear
  error instead of silent timeouts (legacy-client friendliness); fold
  plan_window into cfg(test); drop the unused-constant keep-alive hack.
- rustfmt sweep over the six files with accumulated drift.

Hot-path optimizations (all behavior-preserving):
- Serve embedded iPXE binaries zero-copy (Cow over rodata) on both TFTP
  and HTTP — was a ~1 MiB heap copy per boot file request.
- Cache the composited PXE boot-menu background PNG keyed on the
  branding logo revision — was ~50-200 ms of image work per booting
  client; now one compose per logo change.
- Run bcrypt verify/hash on the blocking pool (boot password gate,
  login, setup, credential rotation) so CPU-heavy auth can't stall the
  workers streaming ISO ranges to imaging machines.
- iso_raw: reuse the already-cloned IsoMeta for path resolution instead
  of a second registry lock + deep clone per range request.
- DriverEscalation: amortize the TTL sweep (1-min interval + inline
  staleness check) instead of an O(map) retain per DHCP packet.
- format_mac: one allocation instead of four per datagram.
- Introspection haystack sized to min(scan cap, file size) — was
  guaranteed a 32 MiB realloc on every large-ISO probe.

Robustness:
- parse_range: malformed Range headers are now ignored per RFC 7233
  (200 + full body) instead of answered with a bogus 206.

Dependencies:
- dhcproto 0.12 -> 0.15: drops the deprecated/unmaintained
  trust-dns-proto from the tree (hickory-proto), three releases of DHCP
  option coverage. Compiles + passes the full suite unchanged.
- socket2 0.6 (dedupes tree), bcrypt 0.19, tower-http 0.6.11 (sheds
  iri-string), tokio 1.52.3 / hyper 1.10 lockfile refresh; dead nom
  workspace entry removed; requested versions synced to shipped reality.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-09 16:44:55 -04:00
Miles WardandClaude Opus 4.8 4f193cac05 v0.6.1: latest iPXE + automatic NIC driver fallback (more devices, zero toggle)
Mirrors the worthwhile device-support wins from iVentoy 1.0.24→1.0.35 onto our
(very different) proxy-DHCP + iPXE-chainload architecture. iVentoy's other
changes are inapplicable (arm64-server / distro-display fixes live in its
injected Linux, which we don't have), niche (iSCSI), or closed-source
(Matrix Boot).

iPXE refreshed (mirrors 1.0.35 "Update iPXE")
- Pin the from-source build to ipxe/ipxe master @ 2026-06-09
  (95ffbf4745553e8a207922389929e1943c0237c0) — newer NIC drivers + EFI fixes.
  The pin also busts the cached ipxe-build Docker layer so the release
  actually recompiles iPXE; build-ipxe.sh now shallow-fetches an exact SHA.

Automatic NIC driver fallback (mirrors 1.0.34 "driver/boot-file mode" — but
no operator toggle, per request)
- New DriverMode {Firmware, Builtin} in core; ClientArch::ipxe_bootfile_mode
  maps each arch to either the firmware-net build (snponly/undionly, default)
  or the all-drivers build (ipxe.efi/ipxe.pxe/ipxe-i386.efi/ipxe-arm64.efi).
- The DHCP proxy serves Firmware by default — byte-for-byte unchanged, so
  hardware that boots today never regresses. A new DriverEscalation state
  machine watches for the tell-tale failure: a MAC re-PXE-boots (fresh
  firmware DISCOVER) without ever completing the iPXE-user-class handoff that
  proves the firmware NIC stack worked. That MAC is automatically escalated to
  iPXE's own NIC drivers, and the choice is sticky after a confirmed handoff
  (debounced for the :67/:4011 same-boot pair, TTL-pruned, capped). It just
  works — no settings, no UI.
- All-drivers binaries fetched per arch (ipxe.pxe + i386/arm64 native EFI;
  x86_64 ipxe.efi already built from source with PNG); ipxe-assets embeds
  *.pxe and logs availability per (arch, mode).

Core principles intact: DHCP-proxy-only, container-first, Rust-focused (the
logic is all Rust; only the iPXE fetch/build stays shell), Windows hard-rules
untouched (this never goes near Windows boot).

Validation: clippy clean; full workspace test suite green (core 99 incl. new
DriverMode tests, dhcp-proxy +4 escalation tests, http-api 31+68, iso-store
61, tftp 6, bin 2); fmt-clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-09 11:18:07 -04:00
mward4 24879fcc90 Update README.md 2026-06-05 13:13:07 -04:00
Miles WardandClaude Opus 4.8 5df0fd5972 v0.6.0: bootable-ISO polish + close the 0.5.x chapter
Builds on v0.5.9's El Torito detection to make the boot menu honest and
clean, and confirms generic El Torito ISOs (ESXi/VMvisor installers, BSDs,
firmware tools) boot via iPXE sanboot with no special-casing:

- generate_boot_entries: an Unknown-family ISO now produces a sanboot entry
  only when it's actually bootable — it carries an El Torito catalog, OR it's
  a remote-share ISO we couldn't introspect (rev 0, assumed bootable). A
  locally-introspected ISO with no boot catalog (a data/appliance image like
  a VMware vCenter Server Appliance bundle) yields NO entry, so it stays out
  of the iPXE menu instead of offering a pick that always fails. ESXi
  installers (Unknown family + El Torito) surface under the installer menu
  and sanboot the raw ISO — backed by HTTP range reads, so size is moot.
- Dropped the stale "(SAN boot — may fail for >1GiB ISOs)" disclaimer and
  refreshed the SanBootIso doc: sanboot is the primary path for Windows and
  any El Torito image, and HTTP range reads remove the size limit.
- WebUI: renamed the dashboard panel "Images that won't boot with current
  settings" -> "Non-bootable images" (there's no setting that would make a
  data/appliance ISO boot).
- Tests: el_torito catalog detection + boot-entry generation across the
  ESXi / VCSA / remote-share cases.

Full v0.5.0->v0.5.9 compatibility sweep: clippy clean; entire workspace test
suite green (core 96, http-api 31+68, iso-store 61, dhcp 1, tftp 6, bin 2);
app.js syntax-checked.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-05 13:02:30 -04:00
Miles WardandClaude Opus 4.8 06695c3d77 v0.5.9: El Torito boot detection + retroactive re-introspect; static SSO login button
Storage / boot detection
- Add El Torito boot-catalog detection to ISO introspection. This is the
  authoritative "can this boot at all?" signal: any ISO with a boot catalog
  (BSDs, ESXi, firmware tools, custom spins) is bootable via iPXE sanboot;
  a data/appliance ISO (e.g. a VMware vCenter bundle) has none and is
  honestly flagged. Replaces the crude ">1.5 GB ⇒ unbootable" size guess.
- Re-introspect stale LOCAL ISOs on startup via an introspection-revision
  gate (INTROSPECT_REV). ISOs uploaded by an older binary carried a frozen
  family/boot profile — most visibly a Windows 11 ISO tagged Unknown before
  the UDF/UTF-16 detection landed, which then showed "won't boot" forever.
  An upgrade now re-probes and fixes them in place; no delete-and-re-upload.
- WebUI bootability() keys off family / kernel / el_torito / remote-source
  instead of the size heuristic; dashboard family counts now bucket
  Windows / Linux / other honestly instead of lumping everything non-Windows
  under "Linux".

SSO login button
- The "Sign in with …" button keyed off the auth-gated /api/sso, which 401s
  pre-auth — so the button only survived on a stale in-memory config and
  vanished instance-wide on any fresh login-page load. Ship a minimal,
  non-sensitive SSO descriptor (enabled + idp_name + idp_logo_url, no
  metadata/entity-ID) on the public /api/me; the login card reads that.
  The button is now static whenever SSO is usable.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-05 11:47:41 -04:00
mward4 cb51b8db75 Update README.md 2026-06-05 04:34:28 -04:00
Miles Ward d6a9df85d7 chore: drop local editor settings folder from the repo 2026-06-05 04:30:41 -04:00
Miles WardandClaude Opus 4.8 9fc9a9a1af 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 ac433b30e9 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 c0d17fa9ca 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 edf3a69daa 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 44a2212abe 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 674a69f93b 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 7358013093 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 a906c47f53 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
56 changed files with 7359 additions and 1415 deletions
+3
View File
@@ -11,3 +11,6 @@ data/work/
.claude/settings.local.json .claude/settings.local.json
.claude/worktrees/ .claude/worktrees/
.claude/scheduled_tasks.lock .claude/scheduled_tasks.lock
# local editor / agent settings (not part of the project)
.claude/
Generated
+1668 -495
View File
File diff suppressed because it is too large Load Diff
+42 -13
View File
@@ -12,7 +12,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.5.2" version = "0.7.2"
edition = "2021" edition = "2021"
rust-version = "1.95" rust-version = "1.95"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
@@ -20,41 +20,45 @@ repository = "https://gitea.milesward.dev/mward4/OpenPXE"
authors = ["OpenPXE contributors"] authors = ["OpenPXE contributors"]
[workspace.dependencies] [workspace.dependencies]
tokio = { version = "1.40", features = ["full"] } tokio = { version = "1.52", features = ["full"] }
tokio-util = { version = "0.7", features = ["io"] } tokio-util = { version = "0.7", features = ["io"] }
tokio-stream = { version = "0.1", features = ["sync"] } tokio-stream = { version = "0.1", features = ["sync"] }
futures = "0.3" futures = "0.3"
async-trait = "0.1" async-trait = "0.1"
dhcproto = "0.12" # v0.6.2: dhcproto 0.15 drops the deprecated trust-dns-proto dependency
socket2 = { version = "0.5", features = ["all"] } # (replaced by hickory-proto) and carries three releases of DHCP option
# coverage accumulated upstream — both directly relevant to the proxy core.
dhcproto = "0.15"
socket2 = { version = "0.6", features = ["all"] }
bytes = "1.7" bytes = "1.7"
nom = "7.1"
axum = { version = "0.7", features = ["macros", "multipart", "http2"] } axum = { version = "0.8", features = ["macros", "multipart", "http2"] }
tower = "0.5" tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] } tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] }
hyper = "1.4" hyper = "1.9"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
mime = "0.3"
mime_guess = "2.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
toml = "0.8" toml = "0.8"
# v0.5.4: layered config (TOML file + env). Pure-Rust, no C deps; keeps the
# static-musl build OpenSSL-free. Replaces the hand-rolled apply_env mapping.
figment = { version = "0.10", features = ["toml", "env"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
anyhow = "1.0" anyhow = "1.0"
thiserror = "1.0" thiserror = "2.0"
clap = { version = "4.5", features = ["derive", "env"] } clap = { version = "4.5", features = ["derive", "env"] }
uuid = { version = "1.10", features = ["v4", "serde"] } uuid = { version = "1.10", features = ["v4", "serde"] }
time = { version = "0.3", features = ["serde", "serde-human-readable", "formatting", "macros"] } time = { version = "0.3", features = ["serde", "serde-human-readable", "formatting", "macros"] }
# sha2 stays 0.10 deliberately: bergshamra-crypto requires ^0.10, and
# bumping to 0.11 would split the RustCrypto digest stack in the tree.
sha2 = "0.10" sha2 = "0.10"
hex = "0.4" hex = "0.4"
bcrypt = "0.15" bcrypt = "0.19"
once_cell = "1.19"
parking_lot = "0.12" parking_lot = "0.12"
rust-embed = { version = "8.5", features = ["include-exclude"] } rust-embed = { version = "8.5", features = ["include-exclude"] }
@@ -76,7 +80,7 @@ lettre = { version = "0.11", default-features = false, features = ["smtp-transpo
# C deps), so the static musl binary stays OpenSSL-free — samael was # C deps), so the static musl binary stays OpenSSL-free — samael was
# rejected precisely because it hard-requires OpenSSL. We build the thin # rejected precisely because it hard-requires OpenSSL. We build the thin
# SP layer (AuthnRequest, metadata parse, SAMLResponse semantics) on top. # SP layer (AuthnRequest, metadata parse, SAMLResponse semantics) on top.
bergshamra = "0.4" bergshamra = "0.5"
roxmltree = "0.21" roxmltree = "0.21"
quick-xml = "0.40" quick-xml = "0.40"
x509-parser = "0.18" x509-parser = "0.18"
@@ -85,6 +89,31 @@ x509-parser = "0.18"
flate2 = "1.1" flate2 = "1.1"
base64 = "0.22" base64 = "0.22"
# v0.5.5: pure-Rust SSH/SFTP client for reading remote ISO libraries
# over SFTP without a kernel mount.
#
# CRITICAL #1 — crypto backend: `default-features = false` +
# `features = ["ring"]`. russh's *default* backend is `aws-lc-rs`, which
# pulls `aws-lc-sys` (C code, fiddly under musl); the `ring` feature
# instead reuses `ring 0.17` — the exact crate+version already in the
# binary via rustls + bergshamra — so SFTP adds ZERO new C/crypto deps
# and the static-musl build stays OpenSSL-free.
#
# CRITICAL #2 — history: this was pinned to =0.55.0 from v0.5.5 until
# v0.6.3 because bergshamra-crypto pinned release-candidate RustCrypto
# crates that conflicted with the stable generation russh 0.56+ pulls.
# bergshamra 0.5 (2026-06) moved to the stable generation (pkcs8 0.11),
# lifting the pin. v0.6.3 bumps to 0.61+, which also closes a batch of
# RUSTSEC advisories reachable from the SFTP *client* path (unbounded
# allocations in packet parsing — CVE-2026-48110/-46702/-46673 et al.)
# and drops mlock on non-secret buffers (~21% SSH throughput upstream).
#
# SCP was deliberately rejected: the protocol is sequential-only (no
# random access → no HTTP Range, unlike SFTP/NFS) and the mature SCP
# crates wrap libssh2 (C + OpenSSL), which would break this build.
russh = { version = "0.61", default-features = false, features = ["ring"] }
russh-sftp = "2.3"
openpxe-core = { path = "crates/core" } openpxe-core = { path = "crates/core" }
openpxe-dhcp-proxy = { path = "crates/dhcp-proxy" } openpxe-dhcp-proxy = { path = "crates/dhcp-proxy" }
openpxe-tftp = { path = "crates/tftp" } openpxe-tftp = { path = "crates/tftp" }
+200 -231
View File
@@ -1,301 +1,270 @@
# OpenPXE <p align="center">
<img src="docs/openpxe-logo.svg" alt="OpenPXE" width="104" height="104" />
</p>
Container-native PXE boot server. A Rust reimplementation of <h1 align="center">OpenPXE</h1>
[iVentoy (ventoy/PXE)](https://github.com/ventoy/PXE), designed from scratch
for Docker/OCI and OpenShift. Upload `.iso` files via the web UI; network
clients PXE-boot them.
> **Status:** v0.4.1 / pre-beta. Phases 15 complete: full PXE stack, <p align="center">
> Queued Deployment queue, NFS-share ISO sources, live tracing log + an <strong>Container-native network boot &amp; OS deployment — built in Rust.</strong>
> operator terminal, per-MAC host bindings, Prometheus `/metrics`, </p>
> light/dark theme toggle, animated OpenPXE imaging-progress widget,
> chunked ISO uploads, and per-ISO boot passwords. The test suite and
> clippy are part of the release checklist. Ready for real-hardware validation.
## Design non-negotiables <p align="center">
Drag in an ISO. PXE-boot and image an entire fleet from a browser.<br/>
No iPXE scripting. No <code>dnsmasq</code> + <code>tftpd</code> + Samba glue. No glibc. No garbage collector.
</p>
1. **Fully offline / air-gap deployable.** Zero CDN assets. Zero external <p align="center">
HTTP calls from the server, the browser, or the generated iPXE scripts. <img alt="release" src="https://img.shields.io/badge/release-v0.6.0-2874d7" />
Build the container once, run forever disconnected. <img alt="license" src="https://img.shields.io/badge/license-MIT%20%7C%20Apache--2.0-59824f" />
2. **iPXE is a backend implementation detail.** No `.ipxe` upload path, no <img alt="rust" src="https://img.shields.io/badge/built%20with-Rust-fb8841?logo=rust&logoColor=white" />
manual script editing, no iPXE terminology in the UI. Every knob in the <img alt="container" src="https://img.shields.io/badge/container--native-OCI%20%C2%B7%20OpenShift-2496ED?logo=docker&logoColor=white" />
web UI maps to a specific script-generation behavior inside the binary. <img alt="binary" src="https://img.shields.io/badge/static-musl%20%C2%B7%20~18MB-330f1f" />
3. **The client trust store is off-limits.** No test-signed drivers, no </p>
`bcdedit /set testsigning on`, no certificates injected into WinPE or
the target OS.
## What it does ---
1. **DHCP proxy** (RFC 4578). Coexists with your existing DHCP server — OpenPXE turns bare-metal provisioning into a single container with a web UI. It's a
never assigns IPs. Listens on UDP 67 + UDP 4011. ground-up Rust reimplementation of [iVentoy (ventoy/PXE)](https://github.com/ventoy/PXE),
2. **TFTP server** (RFC 1350 + RFC 2347/2348/2349/7440 option negotiation) designed for Docker/OCI and OpenShift instead of a Windows desktop — so it drops onto
that serves architecture-specific iPXE binaries to firmware PXE ROMs. an Unraid box, a Linux server, or a Kubernetes cluster and just runs.
3. **HTTP server** that serves the web UI, the generated iPXE boot scripts,
raw ISOs (with Range), and files inside ISOs without prior extraction.
4. **ISO introspection**: auto-detects the distro family and generates the
appropriate kernel+initrd or wimboot chain. No manual config.
5. **Hierarchical PXE menu** mirroring the Phase 2 spec:
```
Default > Boot from Local HDD
Installers > Linux Installers / Windows Installers
Tools > Utilities / OpenPXE Shell / Network Card Info
Queued Deployment
```
6. **Queued Deployment queue** — the coordinated launch flow. A client that
selects *Queued Deployment* gets a numbered position and waits. The
operator picks an ISO in the web UI and fires it to every waiting
client simultaneously.
7. **Web UI** (Netbox-style): sidebar nav (Dashboard / Network / Queue /
Storage / Hosts / Terminal / About), light + dark themes
(toggle top-right or press `T`), animated OpenPXE progress
widget when devices are imaging. All assets served from the binary —
no external requests.
8. **Per-MAC host bindings.** Pin a MAC to a boot target and the client
skips the menu, chains straight through.
9. **Prometheus metrics** at `/metrics` — DHCP replies by arch, TFTP
transfer counts and bytes, HTTP request counts by route, queue /
imaging gauges, uptime, build info. Plain text exposition format,
no external metrics framework dependency.
8. **Settings API** lets you change the default boot-menu timeout (default
600s), the timeout action (stay / Local HDD / Queued Deployment), and
feature toggles like Windows ISO support. The iPXE scripts regenerate
on every request using current settings.
### Architectures supported on day one Upload `.iso` files (or point at a remote share), and any machine on the network boots
them — Linux installers, live tools, or stock Windows setup — with **zero iPXE knowledge
required by the operator.**
| DHCP option 93 | Architecture | Binary served | > **Status — v0.6.0, late pre-beta.** The full PXE stack, web UI, remote ISO libraries
|----------------|-----------------|-------------------------| > (SMB/NFS/SFTP), Windows deployment, queued fleet rollout, SAML SSO, and Prometheus
| `0x0000` | Legacy x86 BIOS | `undionly.kpxe` | > metrics are implemented and test-covered. The release checklist gates every tag on the
| `0x0006` | IA32 UEFI | `snponly-i386.efi` | > full test suite + `clippy`. Currently in real-hardware validation.
| `0x0007`/`0x0009` | x86_64 UEFI | `snponly.efi` |
| `0x000B` | ARM64 UEFI | `snponly-arm64.efi` |
UEFI firmware that sends `HTTPClient` in option 60 is handled too — we ## Why OpenPXE
skip TFTP and respond with an HTTP URL.
## Quick start — MVP container (recommended) Standing up network boot the traditional way means hand-wiring `dnsmasq`, a TFTP daemon,
hand-written iPXE menu scripts, an HTTP server, and Samba — then keeping that fragile
stack alive, and discovering none of it containerizes cleanly (kernel-mount NFS, raw
sockets, `CAP_SYS_ADMIN`). iVentoy solved the UX beautifully, but it's a Windows GUI app.
OpenPXE collapses that whole stack into **one statically-linked binary in one container**:
- **A web UI does everything.** iPXE is an internal implementation detail — there is no
script upload, no `.ipxe` editing, no PXE jargon in the interface.
- **It runs anywhere a container runs.** No kernel modules, no privileged mode — proxy-mode
DHCP + `NET_BIND_SERVICE` is the entire requirement. Verified on Unraid, plain Docker,
and OpenShift's restricted SCC.
- **It's air-gap native.** Zero CDN assets, zero outbound calls from the server, browser,
or generated boot scripts. Build the image once, run it forever, disconnected.
## Highlights
#### Boot stack
- **DHCP proxy** (RFC 4578) that coexists with your existing DHCP — it never hands out IPs.
- **TFTP** (RFC 1350 + 2347/2348/2349/7440 option negotiation) serving arch-correct iPXE firmware.
- **HTTP** serving the UI, generated boot scripts, raw ISOs (with byte-range), and files
*inside* ISOs with no prior extraction.
- **Graphical iPXE boot menu** built from your uploads, with a PNG background and a clean
hierarchy — generated fresh on every request from current settings.
#### ISO management & remote libraries
- **Drag-and-drop chunked uploads** that don't 502 on multi-GB images.
- **Automatic introspection** — detects the distro family and generates the right
kernel+initrd or Windows `wimboot` chain. No manual config.
- **Remote ISO libraries, streamed on demand** (no local cache) over **SMB, NFS, or SFTP**
see the table below.
#### Fleet deployment
- **Queued Deployment** — clients join a queue and wait; the operator fires one image at
every waiting machine simultaneously.
- **Per-MAC host bindings** — pin a MAC straight to a target (with optional auto hostname,
auto IP, and an unattended answer file); it skips the menu and chains through.
- **Unattended installs** — upload Kickstart / Preseed / Autoinstall / Windows answer files;
they're templated per-host (hostname / IP / MAC) and served only to booting clients.
- **Windows deployment** from a stock Microsoft ISO — **every binary the client runs stays
Microsoft-signed** (details below).
#### Operations & access
- **SAML 2.0 single sign-on** (pure-Rust SP, no OpenSSL/xmlsec) alongside local accounts.
- **Custom branding** — light / dark / PXE-client logos and favicon.
- **Notifications** — Slack / Teams / Discord webhooks and SMTP email on boot events.
- **Prometheus `/metrics`**, a built-in operator **terminal**, live tracing log, and
`/healthz` · `/readyz` probes.
- **Layered config** — defaults → TOML file → `OPENPXE_*` env, in that order.
## Built in Rust
Rust isn't a checkbox here — it's why OpenPXE deploys the way it does:
- **One static binary, ~18 MB.** Compiled to `x86_64-unknown-linux-musl` — no glibc, no
interpreter, no sidecar runtime. The runtime image is "binary + a few CLI tools."
- **No garbage collector, async throughout.** A Tokio runtime drives DHCP, TFTP, HTTP, and
many concurrent multi-GB ISO streams on a tiny, predictable memory footprint — it idles
near-zero and never GC-pauses mid-transfer.
- **Memory-safe by construction.** `unsafe` is **denied workspace-wide**; the only
exceptions are two small, individually-audited FFI calls (`statvfs` for disk usage and a
Samba `SIGHUP`).
- **OpenSSL-free, pure-Rust crypto.** TLS via `rustls`/`ring`; the SAML Service Provider
does XML-DSig verification with RustCrypto — no `xmlsec`, no `libxml2`, no C crypto to
CVE-patch. Even the SMB/NFS/SFTP clients avoid C libraries.
- **Sub-minute, reproducible container builds.** Cross-compiled with `cargo-zigbuild`
(zig as the linker) — a full image builds in well under a minute on a warm cache, with
no QEMU emulation.
## Quick start
### Run the container
```bash ```bash
# 1. Pull bundled iPXE binaries (~2 MB, one-time). # Build the self-contained image (iPXE binaries are fetched + built inside the Dockerfile).
./scripts/fetch-ipxe.sh docker build -f deploy/docker/Dockerfile -t openpxe:0.5.5 .
# 2. Build the container image (~3 min first time). # Run it on the box plugged into your PXE network. Host networking is required in
docker buildx build -f deploy/docker/Dockerfile -t openpxe:0.4.1 --load . # proxy mode so the container sees DHCPDISCOVER broadcasts; set PUBLIC_IP to this
# host's LAN address so advertised boot URLs are reachable.
# 3. Run it on the box plugged into your PXE network. Set PUBLIC_IP to docker run -d --name openpxe --network host \
# this host's LAN address so advertised iPXE URLs are reachable.
docker run -d --name openpxe \
--network host \
-e OPENPXE_PUBLIC_IP=10.0.0.5 \ -e OPENPXE_PUBLIC_IP=10.0.0.5 \
-e OPENPXE_DHCP_MODE=proxy \ -e OPENPXE_DHCP_MODE=proxy \
-v $PWD/data/isos:/var/lib/openpxe/isos \ -v $PWD/data/isos:/var/lib/openpxe/isos \
-v $PWD/data/work:/var/lib/openpxe/work \ -v $PWD/data/work:/var/lib/openpxe/work \
openpxe:0.4.1 openpxe:0.5.5
# 4. Open the UI and drop an ISO in. # Open the UI and drop an ISO in.
open http://10.0.0.5 open http://10.0.0.5
``` ```
Host networking is required in proxy mode so the container sees DHCPDISCOVER > On macOS/Windows, Docker runs inside a Linux VM, so "host network" means the VM — use
broadcasts from the PXE VLAN. On macOS/Windows hosts Docker runs in a Linux > the `openpxe-dev` service in `docker-compose.yml` for API-only testing on a laptop:
VM, so "host" means the VM — use `openpxe-dev` in `docker-compose.yml` for > `OPENPXE_PUBLIC_IP=127.0.0.1 docker compose up openpxe-dev`.
API-only testing on a laptop.
### Quick start — docker compose ### Build from source
```bash ```bash
# MVP / API testing on a laptop (no DHCP, high ports): ./scripts/fetch-ipxe.sh # populate assets/ipxe/ (embedded at compile time)
OPENPXE_PUBLIC_IP=127.0.0.1 docker compose up openpxe-dev cargo run --release # needs root or CAP_NET_BIND_SERVICE for :80/:69
# Real PXE deployment on a Linux host (host network, DHCP proxy on):
OPENPXE_PUBLIC_IP=10.0.0.5 docker compose up openpxe
``` ```
### Multi-arch build + push ### Pre-seed ISOs from a directory
For deploying to x86_64 servers, build both arches in one manifest:
```bash
# One-time: bootstrap a multi-arch builder.
docker buildx create --name openpxe-multi --driver docker-container --use
# Build + push both linux/amd64 and linux/arm64 under one tag.
docker buildx build --builder openpxe-multi \
--platform linux/amd64,linux/arm64 \
-t ghcr.io/YOUR-ORG/openpxe:0.4.1 \
--push \
-f deploy/docker/Dockerfile .
```
On an Apple Silicon host, the amd64 stage runs under QEMU emulation (~10-15 min for a cold cache). On a Linux x86_64 host, both arches build natively at normal speed. CI runners on GitHub Actions with `docker/build-push-action@v5` handle this cleanly.
### Build from source (no container)
```bash
./scripts/fetch-ipxe.sh
cargo run --release # needs NET_BIND_SERVICE or root for :80/:69
```
### Container health probes
| Endpoint | Purpose |
|-------------|---------------------------------------------------------------|
| `/healthz` | Liveness — HTTP stack alive. Always 200. |
| `/readyz` | Readiness — 200 only if iPXE binaries bundled + ISO dir OK. |
| `/api/status` | Full JSON status: versions, assets, counts, live settings, SMB state. |
### Pre-seeding ISOs from a directory
For CI, pre-baked homelab deployments, or a fresh PVC, the binary has a
`seed` subcommand that imports every `*.iso` from a host path through the
same pipeline the web UI uses (introspection + boot-entry generation):
```bash ```bash
docker run --rm \ docker run --rm \
-v /my/iso-library:/seed:ro \ -v /my/iso-library:/seed:ro \
-v openpxe-data:/var/lib/openpxe/isos \ -v openpxe-data:/var/lib/openpxe/isos \
-e OPENPXE_PUBLIC_IP=10.0.0.5 \ -e OPENPXE_PUBLIC_IP=10.0.0.5 \
openpxe:0.4.1 seed --from /seed openpxe:0.5.5 seed --from /seed # add --dry-run to preview
# Dry run first to see what would be imported:
docker run --rm -v /my/iso-library:/seed:ro openpxe:0.4.1 seed --from /seed --dry-run
``` ```
### Environment overrides ## Remote ISO libraries
| Var | Default | Meaning | Point OpenPXE at a NAS and boot ISOs straight off it — **read on demand, no local copy**,
|------------------------|-----------------------------|----------------------------------------| so a 50-ISO library costs zero disk on the OpenPXE host. All three clients are userspace
| `OPENPXE_HTTP_PORT` | `80` | Web UI + boot script HTTP port | (no kernel mounts, no `CAP_SYS_ADMIN`); pick whichever your storage speaks.
| `OPENPXE_TFTP_PORT` | `69` | TFTP port |
| `OPENPXE_DHCP_PORT` | `67` | DHCP server-side port |
| `OPENPXE_DHCP_MODE` | `proxy` | `proxy` or `disabled` |
| `OPENPXE_PUBLIC_IP` | auto-detect | Advertised IP for clients. Startup **fails** if unset and auto-detect returns loopback. |
| `OPENPXE_ISO_DIR` | `/var/lib/openpxe/isos` | Where uploaded ISOs live |
| `OPENPXE_WORK_DIR` | `/var/lib/openpxe/work` | Scratch + runtime settings |
| `OPENPXE_LOG` | `info,openpxe=debug` | `tracing` filter |
## What the boot menu looks like on a real client | Protocol | Implementation | Auth | HTTP Range¹ |
|----------|----------------|------|-------------|
| **NFS** (v3) | Pure-Rust in-process client | Client-IP (server export list) | ✅ |
| **SFTP** (SSH) | Pure-Rust in-process client (`russh`) | Password **or** SSH key · host-key TOFU | ✅ |
| **SMB** / CIFS | Userspace `smbclient` | Guest or username/password | — |
¹ Range support lets clients seek into a multi-GB ISO without downloading what comes
before it — needed for kernel/initrd extraction and `httpdisk`-style boots. NFS and SFTP
expose explicit offsets; the SMB CLI streams sequentially, so SMB-sourced ISOs serve whole-file.
## Supported client architectures
| DHCP option 93 | Architecture | Firmware served |
|----------------|--------------|-----------------|
| `0x0000` | Legacy x86 BIOS | `undionly.kpxe` |
| `0x0006` | IA32 UEFI | `snponly-i386.efi` |
| `0x0007` / `0x0009` | x86_64 UEFI | `snponly.efi` |
| `0x000B` | ARM64 UEFI | `snponly-arm64.efi` |
UEFI firmware that advertises `HTTPClient` (option 60) skips TFTP entirely and is handed an HTTP URL.
## The boot menu, on a real client
``` ```
OpenPXE - network boot menu OpenPXE network boot menu
------------------------- Default ------------------------- ------------------------- Default -------------------------
Boot from Local HDD Boot from Local HDD
----------------------- Installers ----------------------- ----------------------- Installers ------------------------
Linux Installers > Linux Installers >
Windows Installers > (only if enabled in Settings) Windows Installers > (only if enabled in Settings)
-------------------------- Tools -------------------------- -------------------------- Tools --------------------------
Tools > Utilities / Shell / Tools > Utilities / OpenPXE Shell / NIC Info / Reboot
NIC Info / Reboot /
Exit and continue BIOS
---------------------- Queued Deployment ------------------ ---------------------- Queued Deployment ------------------
Queued Deployment (join queue) Queued Deployment (join queue)
``` ```
Linux/Windows submenus show file sizes iVentoy-style: Linux/Windows submenus list images iVentoy-style with sizes:
``` ```
OpenPXE - Linux Installers OpenPXE Linux Installers
[ 4376 MB] CentOS-7-x86_64-DVD-1810
[ 2002 MB] Fedora-Workstation-Live-x86_64-38-1.6
[ 4699 MB] ubuntu-22.04.2-desktop-amd64 [ 4699 MB] ubuntu-22.04.2-desktop-amd64
[ 2002 MB] Fedora-Workstation-Live-x86_64-38-1.6
< Back to main menu < Back to main menu
``` ```
iPXE never appears in the UI — the whole hierarchy above is generated from The entire hierarchy is generated from what you upload and toggle — iPXE never surfaces.
ISOs you upload via drag-and-drop in the web UI plus toggles in Settings.
## Windows deployment
Enable **Windows ISO support** in Settings, then upload a **stock, unmodified** Microsoft ISO:
1. On upload, OpenPXE uses `wimlib-imagex` to inject exactly two plain-text files into the
WinPE image (`winpeshl.ini` + `startnet.cmd`) — no drivers, no certificates.
2. The container's Samba `smbd` serves the extracted install tree on `:445`.
3. The client chainloads `wimboot` → patched WinPE → Windows Setup running off the share.
**Every executable the client runs is stock Microsoft-signed.** OpenPXE never ships
drivers, never installs certificates into the client trust store, and never recommends
`bcdedit /set testsigning on`. The SMB approach is adapted (re-implemented, not copied)
from [Bootimus](https://github.com/garybowers/bootimus) (Apache-2.0). Port `445` must be
directly reachable from clients; Windows 10/11 client SKUs are the tested target.
## Configuration
All settings have defaults and layer **defaults → TOML (`--config` / `OPENPXE_CONFIG`) →
`OPENPXE_*` env**. The common knobs:
| Var | Default | Meaning |
|-----|---------|---------|
| `OPENPXE_PUBLIC_IP` | auto-detect | IP advertised to clients. **Startup fails** if unset and auto-detect yields loopback. |
| `OPENPXE_DHCP_MODE` | `proxy` | `proxy` or `disabled` |
| `OPENPXE_HTTP_PORT` | `80` | Web UI + boot-script HTTP port |
| `OPENPXE_TFTP_PORT` | `69` | TFTP port |
| `OPENPXE_DHCP_PORT` | `67` | DHCP server-side port |
| `OPENPXE_ISO_DIR` | `/var/lib/openpxe/isos` | Uploaded ISOs |
| `OPENPXE_WORK_DIR` | `/var/lib/openpxe/work` | Scratch, settings, share + branding state |
| `OPENPXE_LOG` | `info,openpxe=info` | `tracing` filter |
## OpenShift ## OpenShift
```bash ```bash
oc apply -f deploy/openshift/ oc apply -f deploy/openshift/
oc -n openpxe get all
oc -n openpxe get route openpxe -o jsonpath='{.spec.host}' oc -n openpxe get route openpxe -o jsonpath='{.spec.host}'
``` ```
### Why a custom SCC? The bundled `openpxe-scc` grants exactly `hostNetwork` (CNI overlays don't deliver L2
broadcast into pod netns) and `NET_BIND_SERVICE` (to bind ports <1024) — nothing else.
No raw sockets, no privileged mode. The Route covers `80/TCP`; PXE clients reach UDP
67/69/4011 on the node's host IP directly.
The default `restricted-v2` blocks `hostNetwork` and all capabilities. PXE ## Health & observability
cannot work without host network (CNI overlays don't deliver L2 broadcast
into pod netns), and we need `NET_BIND_SERVICE` to bind <1024. The custom
`openpxe-scc` grants exactly those two and nothing else. No raw sockets,
no privileged mode — proxy-mode DHCP sidesteps the usual requirements.
### What's on host ports | Endpoint | Purpose |
|----------|---------|
| Port | Proto | Purpose | | `/healthz` | Liveness — always 200 if the HTTP stack is up. |
|----------|-------|---------------------------------| | `/readyz` | Readiness — 200 only once iPXE firmware is bundled and the ISO dir is reachable. |
| 67 | UDP | DHCP server (proxy replies) | | `/api/status` | Full JSON: version, assets, counts, live settings, share + SMB state. |
| 69 | UDP | TFTP | | `/metrics` | Prometheus text format — DHCP replies by arch, TFTP/HTTP counts, queue gauges, uptime. |
| 4011 | UDP | PXE Boot Server discovery |
| 80 | TCP | Web UI + HTTP boot assets |
The OpenShift Route only covers 80/TCP. Clients on the PXE network talk to
the node's host IP directly for UDP.
## Windows support
Enabled by toggling **Windows ISO support** under Settings. The flow:
1. Upload a stock Microsoft Windows install ISO (vanilla, no pre-processing).
2. On upload, OpenPXE extracts the ISO and uses `wimlib-imagex` to rewrite
image index 2 (WinPE) of `sources/boot.wim`. It injects exactly two
plain-text files:
- `Windows/System32/winpeshl.ini` — tells WinPE to run `startnet.cmd`.
- `Windows/System32/startnet.cmd` — runs `wpeinit`, waits for the SMB
host to be reachable, `net use Z: \\<server>\<share> /user:guest`,
then `Z:\setup.exe`.
3. The container's Samba `smbd` serves the extracted install tree on :445.
4. The client gets chainloaded into wimboot → patched WinPE → Windows Setup
running off the SMB share. **Every binary the client executes is stock
Microsoft-signed.**
### What we never do
- Ship drivers — signed, test-signed, or otherwise — that load on the client.
- Install certificates into the target's trust store or WinPE boot policy.
- Recommend `bcdedit /set testsigning on` or any equivalent signing-policy
weakening.
### Credit & limitations
The SMB-based approach is adapted from [Bootimus](https://github.com/garybowers/bootimus)
(Apache-2.0). Re-implemented in Rust; no code was copied verbatim. Known
operational constraints inherited from the design:
- **Port 445 must be directly reachable from PXE clients.** `net use`
ignores alternate ports. In OpenShift this means `hostPort: 445` on the
deployment; on a host that already runs SMB it will collide.
- Windows 10/11 client SKUs are the tested target. Server SKUs untested.
- Hardware with NICs/storage controllers missing from WinPE's bundled
drivers will need a driver-pack injection step (not yet implemented).
## Queued Deployment
The coordinated launch flow, end to end:
1. A client boots and picks **Queued Deployment** in the PXE menu (or falls
through on timeout with the default `timeout_action`).
2. The client joins the queue, gets a numbered queue position, and enters a
long-poll loop (25s per request, auto-renewed).
3. In the web UI's **Queued Deployment** tab, the operator sees each waiting
client with its MAC, IP, arch, and position.
4. The operator selects an image and clicks **Launch for all waiting**.
The server broadcasts the assignment to every queued client via a
`tokio::sync::Notify`; each client's next poll returns the boot script
for the chosen image.
5. Every client chains the same image at effectively the same moment. The
queue stays visible until the operator releases entries, which keeps a
useful audit trail during hardware testing.
No user-facing iPXE anywhere in this flow. The client only ever runs
scripts we generate; the operator only interacts with the web UI.
## Architecture ## Architecture
See [`docs/architecture.md`](docs/architecture.md) for the protocol stack, Workspace of focused crates — `core`, `dhcp-proxy`, `tftp`, `http-api`, `iso-store`,
crate layout, and the full decision log. `ipxe-assets`, `webui`, and the `openpxe` binary. See
[`docs/architecture.md`](docs/architecture.md) for the protocol stack, crate layout, and
the full decision log.
## Licence ## License
MIT OR Apache-2.0. Dual-licensed under **MIT OR Apache-2.0** — use whichever fits your project.
+4
View File
@@ -13,6 +13,7 @@ workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
toml.workspace = true toml.workspace = true
figment.workspace = true
thiserror.workspace = true thiserror.workspace = true
anyhow.workspace = true anyhow.workspace = true
tracing.workspace = true tracing.workspace = true
@@ -38,6 +39,9 @@ base64.workspace = true
[dev-dependencies] [dev-dependencies]
tempfile = "3.12" tempfile = "3.12"
# v0.5.4: figment's `Jail` (hermetic env/file sandbox) for the config
# loader tests lives behind the `test` feature.
figment = { workspace = true, features = ["test"] }
# v0.5.1: generate a throwaway self-signed signing cert/key so SAML # v0.5.1: generate a throwaway self-signed signing cert/key so SAML
# verification tests can produce genuinely signed SAMLResponses. # verification tests can produce genuinely signed SAMLResponses.
rcgen = "0.13" rcgen = "0.13"
+189 -10
View File
@@ -23,6 +23,36 @@ pub enum ClientArch {
Unknown(u16), Unknown(u16),
} }
/// Which boot binary family to advertise to a client (v0.6.1, extended
/// v0.7.0).
///
/// OpenPXE serves [`DriverMode::Firmware`] first (the firmware's own NIC
/// stack, via `snponly`/`undionly`) and escalates a specific MAC
/// automatically when a boot never completes its handoff:
/// `Firmware → Builtin → Shim`. There is no operator toggle — the DHCP
/// proxy decides per client.
///
/// The `Shim` rung (v0.7.0) covers Secure Boot: firmware with SB enabled
/// downloads our unsigned iPXE fine but refuses to *execute* it, which
/// looks exactly like a failed chainload. After both iPXE builds go
/// unconfirmed, the client is offered the Microsoft-signed shim, which
/// loads the signed GRUB, which fetches a server-rendered menu — a fully
/// signed chain that boots signed distro kernels with SB still on.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DriverMode {
/// Reuse the firmware UNDI/SNP NIC stack (`snponly.efi`, `undionly.kpxe`).
/// Default, smallest, most reliable for chainloading.
#[default]
Firmware,
/// iPXE's own bundled NIC drivers (`ipxe.efi`, `ipxe.pxe`). Fallback for
/// hardware whose firmware NIC stack is missing or buggy.
Builtin,
/// Microsoft-signed shim + GRUB chain (`shimx64.efi`). Final fallback
/// for Secure-Boot-enabled UEFI clients that refuse unsigned iPXE.
Shim,
}
impl ClientArch { impl ClientArch {
#[must_use] #[must_use]
pub fn from_option_93(value: u16) -> Self { pub fn from_option_93(value: u16) -> Self {
@@ -39,21 +69,73 @@ impl ClientArch {
/// Default iPXE binary filename to return via TFTP for this architecture. /// Default iPXE binary filename to return via TFTP for this architecture.
/// Uses `snponly` variants which reuse the firmware's UNDI/SNP network /// Uses `snponly` variants which reuse the firmware's UNDI/SNP network
/// stack — smaller binaries and broader hardware compatibility than the /// stack — smaller binaries and broader hardware compatibility than the
/// all-drivers-included `ipxe.efi`. /// all-drivers-included `ipxe.efi`. Equivalent to
/// [`Self::ipxe_bootfile_mode`] with [`DriverMode::Firmware`]; kept as a
/// convenience for the common firmware-net path.
#[must_use] #[must_use]
pub fn ipxe_bootfile(self) -> Option<&'static str> { pub fn ipxe_bootfile(self) -> Option<&'static str> {
Some(match self { self.ipxe_bootfile_mode(DriverMode::Firmware)
Self::LegacyX86 => "undionly.kpxe", }
Self::Ia32Uefi => "snponly-i386.efi",
Self::X64Uefi => "snponly.efi", /// iPXE binary filename for this architecture under a given network
// ARM32 UEFI: upstream boot.ipxe.org does not publish a prebuilt /// [`DriverMode`].
// snponly variant for this arch. We return None so the DHCP ///
// proxy declines rather than advertising a file we can't serve. /// * [`DriverMode::Firmware`] — the `snponly`/`undionly` builds that reuse
Self::Arm32Uefi | Self::Unknown(_) => return None, /// the firmware's UNDI/SNP NIC stack. Smallest, and the most reliable
Self::Arm64Uefi => "snponly-arm64.efi", /// choice for chainloading because the firmware just proved its network
/// works by downloading the NBP. This is the default first attempt.
/// * [`DriverMode::Builtin`] — the all-drivers `ipxe.efi`/`ipxe.pxe`
/// builds that carry iPXE's *own* NIC drivers. The automatic fallback
/// for clients whose firmware NIC stack is missing or buggy (v0.6.1):
/// the DHCP proxy escalates a MAC to this mode when a firmware-net boot
/// never completes the iPXE handoff. iPXE still includes the `snp`
/// driver here too, so it degrades gracefully.
#[must_use]
pub fn ipxe_bootfile_mode(self, mode: DriverMode) -> Option<&'static str> {
Some(match (self, mode) {
// Legacy x86 BIOS: UNDI (firmware) vs full native-driver build.
(Self::LegacyX86, DriverMode::Firmware) => "undionly.kpxe",
(Self::LegacyX86, DriverMode::Builtin) => "ipxe.pxe",
// IA32 UEFI.
(Self::Ia32Uefi, DriverMode::Firmware) => "snponly-i386.efi",
(Self::Ia32Uefi, DriverMode::Builtin) => "ipxe-i386.efi",
// No signed Shim chain for BIOS (no Secure Boot there) or
// IA32 UEFI (Fedora publishes no 32-bit shim; SB-on IA32
// clients are vanishingly rare). Same outcome as the
// no-binary arches below, listed separately for the comment.
#[allow(clippy::match_same_arms)]
(Self::LegacyX86 | Self::Ia32Uefi, DriverMode::Shim) => return None,
// x86_64 UEFI — the overwhelmingly common modern client.
(Self::X64Uefi, DriverMode::Firmware) => "snponly.efi",
(Self::X64Uefi, DriverMode::Builtin) => "ipxe.efi",
(Self::X64Uefi, DriverMode::Shim) => "shimx64.efi",
// ARM64 UEFI.
(Self::Arm64Uefi, DriverMode::Firmware) => "snponly-arm64.efi",
(Self::Arm64Uefi, DriverMode::Builtin) => "ipxe-arm64.efi",
(Self::Arm64Uefi, DriverMode::Shim) => "shimaa64.efi",
// ARM32 UEFI: upstream boot.ipxe.org publishes no prebuilt binary
// for this arch in any mode. Unknown arches likewise. Return
// None so the DHCP proxy declines rather than advertising a file
// we can't serve.
(Self::Arm32Uefi | Self::Unknown(_), _) => return None,
}) })
} }
/// Like [`Self::ipxe_bootfile_mode`], but walks back down the
/// escalation ladder (`Shim → Builtin → Firmware`) when the requested
/// mode has no binary for this arch — e.g. a BIOS client whose
/// escalation state reached `Shim` (BIOS has no Secure Boot) falls
/// back to the all-drivers build instead of being ignored.
#[must_use]
pub fn bootfile_with_fallback(self, mode: DriverMode) -> Option<&'static str> {
let ladder: &[DriverMode] = match mode {
DriverMode::Shim => &[DriverMode::Shim, DriverMode::Builtin, DriverMode::Firmware],
DriverMode::Builtin => &[DriverMode::Builtin, DriverMode::Firmware],
DriverMode::Firmware => &[DriverMode::Firmware],
};
ladder.iter().find_map(|m| self.ipxe_bootfile_mode(*m))
}
#[must_use] #[must_use]
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
@@ -133,6 +215,103 @@ mod tests {
assert_eq!(ClientArch::Unknown(0xFFFF).ipxe_bootfile(), None); assert_eq!(ClientArch::Unknown(0xFFFF).ipxe_bootfile(), None);
} }
#[test]
fn bootfile_default_is_firmware_mode() {
// The convenience method must equal the explicit Firmware mode.
for a in [
ClientArch::LegacyX86,
ClientArch::Ia32Uefi,
ClientArch::X64Uefi,
ClientArch::Arm64Uefi,
ClientArch::Arm32Uefi,
ClientArch::Unknown(0x99),
] {
assert_eq!(
a.ipxe_bootfile(),
a.ipxe_bootfile_mode(DriverMode::Firmware)
);
}
}
#[test]
fn builtin_mode_maps_to_all_drivers_binaries() {
assert_eq!(
ClientArch::LegacyX86.ipxe_bootfile_mode(DriverMode::Builtin),
Some("ipxe.pxe")
);
assert_eq!(
ClientArch::X64Uefi.ipxe_bootfile_mode(DriverMode::Builtin),
Some("ipxe.efi")
);
assert_eq!(
ClientArch::Ia32Uefi.ipxe_bootfile_mode(DriverMode::Builtin),
Some("ipxe-i386.efi")
);
assert_eq!(
ClientArch::Arm64Uefi.ipxe_bootfile_mode(DriverMode::Builtin),
Some("ipxe-arm64.efi")
);
// No binary for ARM32 / unknown in either mode.
assert_eq!(
ClientArch::Arm32Uefi.ipxe_bootfile_mode(DriverMode::Builtin),
None
);
assert_eq!(
ClientArch::Unknown(0x99).ipxe_bootfile_mode(DriverMode::Builtin),
None
);
}
#[test]
fn driver_mode_default_is_firmware() {
assert_eq!(DriverMode::default(), DriverMode::Firmware);
}
#[test]
fn shim_mode_maps_to_signed_chain_on_uefi_only() {
assert_eq!(
ClientArch::X64Uefi.ipxe_bootfile_mode(DriverMode::Shim),
Some("shimx64.efi")
);
assert_eq!(
ClientArch::Arm64Uefi.ipxe_bootfile_mode(DriverMode::Shim),
Some("shimaa64.efi")
);
// No Secure Boot on BIOS, no published 32-bit shim.
assert_eq!(
ClientArch::LegacyX86.ipxe_bootfile_mode(DriverMode::Shim),
None
);
assert_eq!(
ClientArch::Ia32Uefi.ipxe_bootfile_mode(DriverMode::Shim),
None
);
}
#[test]
fn fallback_walks_down_the_ladder() {
// BIOS escalated to Shim → falls back to the all-drivers build.
assert_eq!(
ClientArch::LegacyX86.bootfile_with_fallback(DriverMode::Shim),
Some("ipxe.pxe")
);
// UEFI x64 at Shim gets the real shim.
assert_eq!(
ClientArch::X64Uefi.bootfile_with_fallback(DriverMode::Shim),
Some("shimx64.efi")
);
// Plain modes are unchanged.
assert_eq!(
ClientArch::X64Uefi.bootfile_with_fallback(DriverMode::Firmware),
Some("snponly.efi")
);
// Arches with nothing stay None.
assert_eq!(
ClientArch::Arm32Uefi.bootfile_with_fallback(DriverMode::Shim),
None
);
}
#[test] #[test]
fn firmware_class_detects_ipxe_over_pxeclient() { fn firmware_class_detects_ipxe_over_pxeclient() {
let c = FirmwareClass::classify(Some(b"PXEClient:Arch:00007"), Some(b"iPXE")); let c = FirmwareClass::classify(Some(b"PXEClient:Arch:00007"), Some(b"iPXE"));
+1 -3
View File
@@ -125,9 +125,7 @@ impl AdminStore {
{ {
let mut g = self.inner.write(); let mut g = self.inner.write();
if g.admin.is_some() { if g.admin.is_some() {
return Err(Error::Invalid( return Err(Error::Invalid("admin account already configured".into()));
"admin account already configured".into(),
));
} }
g.admin = Some(admin.clone()); g.admin = Some(admin.clone());
} }
+369
View File
@@ -0,0 +1,369 @@
//! Label-based boot rules + boot-decision webhook (v0.7.0).
//!
//! Generalizes [`crate::host_bindings::HostBindings`] (exact-MAC pins)
//! into ordered, first-match-wins rules over what the boot chain knows
//! about a client — MAC prefix (OUI or longer) and firmware
//! architecture — plus an optional outbound webhook so external
//! automation (CMDB, netbox, a shell script) can decide the boot target
//! per machine, pixiecore-style.
//!
//! Decision order in the boot script handler, most-specific first:
//! 1. exact per-MAC host binding (operator pin)
//! 2. first matching enabled rule here
//! 3. webhook, if configured (fail-open: timeout/error → menu)
//! 4. interactive menu
//!
//! With no rules and no webhook configured the behavior is byte-for-byte
//! what it was before this feature existed — no toggles to flip.
//!
//! Persisted to `<work_dir>/boot_rules.json` with the same "in-memory
//! authoritative, disk is a crash cache, corruption falls back to empty"
//! policy as the host bindings — a bad rules file must never block PXE.
use crate::host_bindings::normalize_mac;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
/// One ordered rule. All present (non-empty) selectors must match —
/// empty selector fields match anything, so a rule with only `arch` set
/// applies to every client of that architecture.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootRule {
/// Case-insensitive MAC prefix, `:`-separated (e.g. `dc:a6:32` for
/// an OUI, or longer). Empty = any MAC.
#[serde(default)]
pub mac_prefix: String,
/// Client architecture selector — matches `ClientArch::as_str()`
/// (`bios`, `uefi-x64`, `uefi-ia32`, `uefi-arm64`). Empty = any.
#[serde(default)]
pub arch: String,
/// Boot entry id (a `BootEntry::id`) or reserved menu name
/// (`_local`, `_queue`, …) to chain to when this rule matches.
/// May be empty for a rule that only pins a driver mode.
#[serde(default)]
pub target: String,
/// v0.7.1: optional first-boot binary pin — `""` (auto: let the
/// escalation ladder decide), `"firmware"`, `"builtin"`, or
/// `"shim"`. Lets an operator declare "this rack is all Secure
/// Boot → serve the signed chain immediately", skipping the
/// learn-by-failing walk entirely for known fleets.
#[serde(default)]
pub driver_mode: String,
/// Rules can be parked without deleting them.
#[serde(default = "default_true")]
pub enabled: bool,
/// Operator note shown in the UI (`"all Pi 4s"`, `"QA rack"`).
#[serde(default)]
pub note: String,
}
fn default_true() -> bool {
true
}
/// The whole persisted config: ordered rules + optional webhook.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct BootRulesConfig {
pub rules: Vec<BootRule>,
/// Optional boot-decision webhook URL. When set, unmatched boots GET
/// `<url>?mac=<mac>&arch=<arch>` and a `200 {"target": "<id>"}`
/// reply chains to that target. Anything else (404, timeout, bad
/// JSON) falls through to the menu. Empty = disabled.
pub webhook_url: String,
}
/// Store for the rules config. Cheap to clone; locks held briefly.
#[derive(Debug, Clone)]
pub struct BootRulesStore {
path: Arc<PathBuf>,
inner: Arc<RwLock<BootRulesConfig>>,
}
impl BootRulesStore {
/// Load from `work_dir/boot_rules.json`, or start empty if absent /
/// unreadable.
#[must_use]
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("boot_rules.json");
let inner = match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str::<BootRulesConfig>(&text) {
Ok(cfg) => cfg,
Err(e) => {
tracing::warn!(
target: "openpxe::boot_rules",
"boot_rules.json present but unreadable ({e}); starting empty"
);
BootRulesConfig::default()
}
},
Err(_) => BootRulesConfig::default(),
};
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(inner)),
}
}
/// Current config snapshot (for the API / UI).
#[must_use]
pub fn snapshot(&self) -> BootRulesConfig {
self.inner.read().clone()
}
/// Replace the whole config (the UI saves the full table at once —
/// rules are ordered, so partial updates would be ambiguous).
pub fn replace(&self, mut cfg: BootRulesConfig) {
for r in &mut cfg.rules {
r.mac_prefix = normalize_mac(&r.mac_prefix);
r.arch = r.arch.trim().to_ascii_lowercase();
r.target = r.target.trim().to_string();
r.driver_mode = r.driver_mode.trim().to_ascii_lowercase();
r.note = r.note.trim().to_string();
}
cfg.webhook_url = cfg.webhook_url.trim().to_string();
*self.inner.write() = cfg;
self.persist();
}
/// Webhook URL, when configured.
#[must_use]
pub fn webhook_url(&self) -> Option<String> {
let g = self.inner.read();
if g.webhook_url.is_empty() {
None
} else {
Some(g.webhook_url.clone())
}
}
/// First enabled rule matching `(mac, arch)`, in stored order.
/// `arch` is the `ClientArch::as_str()` form when the boot chain
/// passed one along, `None` otherwise (older chains).
#[must_use]
pub fn match_target(&self, mac: &str, arch: Option<&str>) -> Option<String> {
self.first_match(mac, arch, |r| {
(!r.target.is_empty()).then(|| r.target.clone())
})
}
/// v0.7.1: first enabled rule that pins a driver mode for `(mac,
/// arch)`. Consulted by the DHCP proxy *before* the automatic
/// escalation ladder — an operator who knows a rack is all Secure
/// Boot pins it to `shim` and those machines never walk the ladder.
/// Unknown mode strings are ignored (forward compatibility).
#[must_use]
pub fn driver_mode_hint(&self, mac: &str, arch: Option<&str>) -> Option<crate::DriverMode> {
self.first_match(mac, arch, |r| match r.driver_mode.as_str() {
"firmware" => Some(crate::DriverMode::Firmware),
"builtin" => Some(crate::DriverMode::Builtin),
"shim" => Some(crate::DriverMode::Shim),
_ => None,
})
}
/// Shared rule-matching walk: returns the first `extract` result from
/// an enabled rule whose selectors match. Rules that match but yield
/// `None` from `extract` (e.g. no target set, or no driver mode set)
/// don't stop the walk — target rules and mode-pin rules coexist.
fn first_match<T>(
&self,
mac: &str,
arch: Option<&str>,
extract: impl Fn(&BootRule) -> Option<T>,
) -> Option<T> {
let mac = normalize_mac(mac);
let g = self.inner.read();
for r in &g.rules {
if !r.enabled {
continue;
}
if !r.mac_prefix.is_empty() && !mac.starts_with(r.mac_prefix.as_str()) {
continue;
}
if !r.arch.is_empty() {
// An arch-selective rule can only match when the chain
// told us the client's arch.
match arch {
Some(a) if a.eq_ignore_ascii_case(&r.arch) => {}
_ => continue,
}
}
if let Some(v) = extract(r) {
return Some(v);
}
}
None
}
fn persist(&self) {
let snap = self.inner.read().clone();
let body = match serde_json::to_vec_pretty(&snap) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::boot_rules", "serialize boot_rules.json: {e}");
return;
}
};
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = self.path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::boot_rules", "write boot_rules.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::warn!(target: "openpxe::boot_rules", "rename boot_rules.json: {e}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn rule(mac_prefix: &str, arch: &str, target: &str) -> BootRule {
BootRule {
mac_prefix: mac_prefix.into(),
arch: arch.into(),
target: target.into(),
driver_mode: String::new(),
enabled: true,
note: String::new(),
}
}
#[test]
fn driver_mode_hint_pins_known_modes_and_ignores_unknown() {
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
let mut sb_rack = rule("aa:bb:cc", "", "");
sb_rack.driver_mode = "SHIM".into(); // normalized on replace
let mut weird = rule("11:22:33", "", "");
weird.driver_mode = "quantum".into(); // unknown → ignored
s.replace(BootRulesConfig {
rules: vec![sb_rack, weird],
webhook_url: String::new(),
});
assert_eq!(
s.driver_mode_hint("aa:bb:cc:00:00:01", None),
Some(crate::DriverMode::Shim)
);
assert_eq!(s.driver_mode_hint("11:22:33:00:00:01", None), None);
assert_eq!(s.driver_mode_hint("99:99:99:00:00:01", None), None);
}
#[test]
fn mode_pin_rule_does_not_shadow_later_target_rule() {
// A mode-only rule and a target rule can both apply to the same
// client: the mode pin must not consume the target walk.
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
let mut pin = rule("aa:bb", "", "");
pin.driver_mode = "builtin".into();
s.replace(BootRulesConfig {
rules: vec![pin, rule("aa:bb", "", "rack-image")],
webhook_url: String::new(),
});
assert_eq!(
s.driver_mode_hint("aa:bb:00:00:00:01", None),
Some(crate::DriverMode::Builtin)
);
assert_eq!(
s.match_target("aa:bb:00:00:00:01", None).as_deref(),
Some("rack-image")
);
}
#[test]
fn empty_config_matches_nothing() {
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
assert!(s
.match_target("aa:bb:cc:dd:ee:ff", Some("uefi-x64"))
.is_none());
assert!(s.webhook_url().is_none());
}
#[test]
fn first_match_wins_in_order() {
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
s.replace(BootRulesConfig {
rules: vec![
rule("aa:bb:cc", "", "rack-image"),
rule("", "", "catch-all"),
],
webhook_url: String::new(),
});
assert_eq!(
s.match_target("AA-BB-CC-00-00-01", None).as_deref(),
Some("rack-image")
);
assert_eq!(
s.match_target("11:22:33:44:55:66", None).as_deref(),
Some("catch-all")
);
}
#[test]
fn arch_selector_requires_known_arch() {
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
s.replace(BootRulesConfig {
rules: vec![rule("", "uefi-arm64", "arm-image")],
webhook_url: String::new(),
});
assert_eq!(
s.match_target("aa:bb:cc:00:00:01", Some("uefi-arm64"))
.as_deref(),
Some("arm-image")
);
// Wrong arch, or arch unknown to the chain → no match.
assert!(s.match_target("aa:bb:cc:00:00:01", Some("bios")).is_none());
assert!(s.match_target("aa:bb:cc:00:00:01", None).is_none());
}
#[test]
fn disabled_rules_are_skipped() {
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
let mut r = rule("", "", "x");
r.enabled = false;
s.replace(BootRulesConfig {
rules: vec![r],
webhook_url: String::new(),
});
assert!(s.match_target("aa:bb:cc:00:00:01", None).is_none());
}
#[test]
fn config_round_trips_to_disk() {
let dir = tempdir().unwrap();
let s = BootRulesStore::load_or_default(dir.path());
s.replace(BootRulesConfig {
rules: vec![rule("DC-A6-32", "", "pi-image")],
webhook_url: " http://automation/boot ".into(),
});
drop(s);
let s2 = BootRulesStore::load_or_default(dir.path());
// Prefix was normalized on replace, webhook trimmed.
assert_eq!(
s2.match_target("dc:a6:32:01:02:03", None).as_deref(),
Some("pi-image")
);
assert_eq!(s2.webhook_url().as_deref(), Some("http://automation/boot"));
}
#[test]
fn corrupt_file_falls_back_to_empty() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("boot_rules.json"), b"{nope").unwrap();
let s = BootRulesStore::load_or_default(dir.path());
assert!(s.snapshot().rules.is_empty());
}
}
+138
View File
@@ -0,0 +1,138 @@
//! One-time(ish) access tokens for unattended answer files (v0.7.0).
//!
//! Why: answer files routinely embed credentials (local admin passwords,
//! domain-join accounts, root hashes). Serving them to anyone who can
//! GET `/unattended/<id>` is exactly the exposure that got WDS
//! hands-free deployment disabled upstream (CVE-2026-0386 hardening
//! guidance). OpenPXE generates every answer-file URL it injects into a
//! boot chain, so it can scope each URL to the boot that requested it:
//! when a boot script is rendered, a short-lived token is minted and
//! appended; the serving endpoint requires it (or a logged-in operator
//! session, so browser testing keeps working).
//!
//! Deliberately multi-use within the TTL rather than strictly one-shot:
//! real installers fetch the same file more than once (initramfs +
//! installer stage, cloud-init retries), and the token's job is to stop
//! *unrelated* hosts from harvesting credentials, not to count fetches.
//!
//! In-memory only. A server restart invalidates outstanding tokens —
//! acceptable because a restart also interrupts the ISO streaming an
//! in-flight install depends on, and the next boot mints fresh ones.
use parking_lot::Mutex;
use std::collections::HashMap;
use std::time::{Duration, Instant};
use uuid::Uuid;
/// Long enough to cover a slow OS install end-to-end (the answer file is
/// fetched early, but cloud-init can re-read late), short enough that a
/// leaked URL goes stale the same afternoon.
const TOKEN_TTL: Duration = Duration::from_hours(4);
/// Hard cap on outstanding tokens; past it the oldest is evicted. Tokens
/// are minted once per boot-script render, so this only matters under
/// abuse, and serving must never become a memory-growth vector.
const MAX_TOKENS: usize = 4096;
#[derive(Debug, Clone)]
struct Grant {
file_id: String,
issued: Instant,
}
/// In-memory token table. Cheap to clone (`Arc`-shared).
#[derive(Debug, Clone, Default)]
pub struct BootTokens {
inner: std::sync::Arc<Mutex<HashMap<String, Grant>>>,
}
impl BootTokens {
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Mint a token granting access to unattended file `file_id` for the
/// next [`TOKEN_TTL`]. Returns the opaque token value to embed in the
/// generated URL.
#[must_use]
pub fn mint(&self, file_id: &str) -> String {
self.mint_at(file_id, Instant::now())
}
/// Is `token` a live grant for `file_id`?
#[must_use]
pub fn check(&self, token: &str, file_id: &str) -> bool {
self.check_at(token, file_id, Instant::now())
}
fn mint_at(&self, file_id: &str, now: Instant) -> String {
let token = Uuid::new_v4().simple().to_string();
let mut g = self.inner.lock();
g.retain(|_, gr| now.duration_since(gr.issued) < TOKEN_TTL);
if g.len() >= MAX_TOKENS {
if let Some(oldest) = g
.iter()
.min_by_key(|(_, gr)| gr.issued)
.map(|(k, _)| k.clone())
{
g.remove(&oldest);
}
}
g.insert(
token.clone(),
Grant {
file_id: file_id.to_string(),
issued: now,
},
);
token
}
fn check_at(&self, token: &str, file_id: &str, now: Instant) -> bool {
let g = self.inner.lock();
g.get(token)
.is_some_and(|gr| gr.file_id == file_id && now.duration_since(gr.issued) < TOKEN_TTL)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mint_then_check_round_trip() {
let t = BootTokens::new();
let tok = t.mint("ks-1");
assert!(t.check(&tok, "ks-1"));
// Multi-use within TTL: a second fetch still passes.
assert!(t.check(&tok, "ks-1"));
// Wrong file id never passes, even with a live token.
assert!(!t.check(&tok, "ks-2"));
// Unknown token never passes.
assert!(!t.check("nope", "ks-1"));
}
#[test]
fn token_expires_after_ttl() {
let t = BootTokens::new();
let now = Instant::now();
let tok = t.mint_at("ks-1", now);
let just_before = TOKEN_TTL.checked_sub(Duration::from_secs(1)).unwrap();
assert!(t.check_at(&tok, "ks-1", now + just_before));
assert!(!t.check_at(&tok, "ks-1", now + TOKEN_TTL + Duration::from_secs(1)));
}
#[test]
fn table_is_capped() {
let t = BootTokens::new();
let now = Instant::now();
let first = t.mint_at("f", now);
for i in 0..MAX_TOKENS {
let _ = t.mint_at(&format!("f{i}"), now + Duration::from_secs(1));
}
// The oldest grant was evicted to stay within the cap.
assert!(!t.check_at(&first, "f", now + Duration::from_secs(2)));
assert!(t.inner.lock().len() <= MAX_TOKENS);
}
}
-17
View File
@@ -12,11 +12,9 @@ use time::OffsetDateTime;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ClientEvent { pub enum ClientEvent {
DhcpDiscover, DhcpDiscover,
DhcpRequest,
PxeBootServerRequest, PxeBootServerRequest,
TftpRead { file: String }, TftpRead { file: String },
HttpScriptFetch { target: String }, HttpScriptFetch { target: String },
HttpIsoAsset { file: String },
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -32,8 +30,6 @@ pub struct ClientSnapshot {
// Events are left with default serialization (9-tuple) — they're // Events are left with default serialization (9-tuple) — they're
// diagnostic only and not consumed by the UI today. // diagnostic only and not consumed by the UI today.
pub events: Vec<(OffsetDateTime, ClientEvent)>, pub events: Vec<(OffsetDateTime, ClientEvent)>,
/// The boot target (ISO id) last selected via the iPXE menu, if any.
pub selected_target: Option<String>,
} }
#[derive(Debug, Default)] #[derive(Debug, Default)]
@@ -66,7 +62,6 @@ impl ClientRegistry {
first_seen: now, first_seen: now,
last_seen: now, last_seen: now,
events: Vec::new(), events: Vec::new(),
selected_target: None,
}); });
entry.last_seen = now; entry.last_seen = now;
if ip.is_some() { if ip.is_some() {
@@ -84,13 +79,6 @@ impl ClientRegistry {
} }
} }
pub fn set_selected_target(&self, mac: &str, target: Option<String>) {
let mut guard = self.inner.write();
if let Some(c) = guard.get_mut(mac) {
c.selected_target = target;
}
}
#[must_use] #[must_use]
pub fn list(&self) -> Vec<ClientSnapshot> { pub fn list(&self) -> Vec<ClientSnapshot> {
let guard = self.inner.read(); let guard = self.inner.read();
@@ -99,9 +87,4 @@ impl ClientRegistry {
v.sort_by_key(|c| std::cmp::Reverse(c.last_seen)); v.sort_by_key(|c| std::cmp::Reverse(c.last_seen));
v v
} }
#[must_use]
pub fn get(&self, mac: &str) -> Option<ClientSnapshot> {
self.inner.read().get(mac).cloned()
}
} }
+153 -40
View File
@@ -1,3 +1,5 @@
use figment::providers::{Env, Format, Serialized, Toml};
use figment::Figment;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr}; use std::net::{IpAddr, Ipv4Addr};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -38,10 +40,6 @@ pub struct NetworkConfig {
pub dhcp_port: u16, pub dhcp_port: u16,
/// UDP port for PXE Boot Server discovery. Standard is 4011. /// UDP port for PXE Boot Server discovery. Standard is 4011.
pub pxe_port: u16, pub pxe_port: u16,
/// Optional allowlist of client MAC prefixes (OUI). Empty = serve everyone.
pub mac_allowlist: Vec<String>,
/// Optional allowlist of subnets (CIDR). Empty = serve everyone.
pub subnet_allowlist: Vec<String>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
@@ -56,6 +54,9 @@ pub enum DhcpMode {
/// Disabled — rely on an external DHCP server that has been manually /// Disabled — rely on an external DHCP server that has been manually
/// configured with `next-server` / `filename`. OpenPXE only serves TFTP /// configured with `next-server` / `filename`. OpenPXE only serves TFTP
/// + HTTP in this mode. Useful for home routers that can be pre-set. /// + HTTP in this mode. Useful for home routers that can be pre-set.
// `off`/`none` are accepted as aliases for backward-compat with the old
// hand-rolled `apply_env`, which mapped them to Disabled.
#[serde(alias = "off", alias = "none")]
Disabled, Disabled,
} }
@@ -100,8 +101,6 @@ impl Default for NetworkConfig {
dhcp_bind: IpAddr::V4(Ipv4Addr::UNSPECIFIED), dhcp_bind: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
dhcp_port: 67, dhcp_port: 67,
pxe_port: 4011, pxe_port: 4011,
mac_allowlist: Vec::new(),
subnet_allowlist: Vec::new(),
} }
} }
} }
@@ -129,48 +128,162 @@ impl Config {
toml::from_str(&text).map_err(|e| crate::Error::Config(e.to_string())) toml::from_str(&text).map_err(|e| crate::Error::Config(e.to_string()))
} }
/// Apply environment variable overrides. Env var names follow the pattern /// Load configuration with layered precedence (v0.5.4, via `figment`):
/// `OPENPXE_<SECTION>_<FIELD>`, uppercase. Unknown vars are ignored. /// built-in [`Default`] → optional TOML file → `OPENPXE_*` environment
/// Call this after loading the TOML file so env takes precedence. /// (highest). Replaces the old `from_toml_file` + `apply_env` two-step
pub fn apply_env(&mut self) { /// and now covers **every** field automatically (the previous hand-rolled
if let Ok(v) = std::env::var("OPENPXE_HTTP_PORT") { /// mapping silently skipped `unattended_dir`, the bind addresses, etc.).
if let Ok(p) = v.parse() { ///
self.server.http_port = p; /// The env layer preserves the historical flat names
/// (`OPENPXE_HTTP_PORT`, `OPENPXE_ISO_DIR`, …) so existing deployments
/// (the Unraid template, `entrypoint.sh`) keep working unchanged, and
/// additionally accepts the explicit nested form
/// `OPENPXE_<SECTION>__<FIELD>` (double underscore).
pub fn load(path: Option<&Path>) -> crate::Result<Self> {
let mut fig = Figment::from(Serialized::defaults(Config::default()));
if let Some(p) = path {
if p.exists() {
fig = fig.merge(Toml::file(p));
} }
} }
if let Ok(v) = std::env::var("OPENPXE_TFTP_PORT") { fig = fig.merge(env_provider());
if let Ok(p) = v.parse() { fig.extract()
self.server.tftp_port = p; .map_err(|e| crate::Error::Config(e.to_string()))
} }
} }
if let Ok(v) = std::env::var("OPENPXE_DHCP_PORT") {
if let Ok(p) = v.parse() { /// The `OPENPXE_*` environment provider. Maps the historical flat variable
self.network.dhcp_port = p; /// names onto the nested [`Config`] fields, and also accepts the explicit
} /// `OPENPXE_SECTION__FIELD` nested form. Keys that match nothing (e.g.
} /// `OPENPXE_CONFIG`, `OPENPXE_UID` from the entrypoint) become stray
if let Ok(v) = std::env::var("OPENPXE_PUBLIC_IP") { /// top-level keys that `Config` ignores on extract.
if let Ok(ip) = v.parse() { fn env_provider() -> Env {
self.server.public_ip = Some(ip); Env::prefixed("OPENPXE_")
} .map(|key| {
} // Lowercase so the match is robust regardless of how the OS
if let Ok(v) = std::env::var("OPENPXE_DHCP_MODE") { // reports the var's case.
self.network.dhcp_mode = match v.to_ascii_lowercase().as_str() { let k = key.as_str().to_ascii_lowercase();
"proxy" => DhcpMode::Proxy, let mapped = match k.as_str() {
"disabled" | "off" | "none" => DhcpMode::Disabled, "http_port" => "server.http_port",
_ => self.network.dhcp_mode, "http_bind" => "server.http_bind",
"tftp_port" => "server.tftp_port",
"tftp_bind" => "server.tftp_bind",
"public_ip" => "server.public_ip",
"dhcp_port" => "network.dhcp_port",
"dhcp_bind" => "network.dhcp_bind",
"dhcp_mode" => "network.dhcp_mode",
"pxe_port" => "network.pxe_port",
"iso_dir" => "paths.iso_dir",
"work_dir" => "paths.work_dir",
"ipxe_dir" => "paths.ipxe_dir",
"smb_dir" => "paths.smb_dir",
"wimboot_path" => "paths.wimboot_path",
"unattended_dir" => "paths.unattended_dir",
// Unknown: support the explicit nested form
// (OPENPXE_SERVER__HTTP_PORT). `replace` is a no-op for the
// already-handled flat names above.
other => return other.replace("__", ".").into(),
}; };
mapped.into()
})
.split(".")
} }
if let Ok(v) = std::env::var("OPENPXE_ISO_DIR") {
self.paths.iso_dir = PathBuf::from(v); #[cfg(test)]
mod tests {
// figment's `Jail::expect_with` closure returns `Result<(), figment::Error>`
// and `figment::Error` is large; that's the library's API, not ours.
#![allow(clippy::result_large_err)]
use super::*;
#[test]
fn defaults_load_when_no_file_or_env() {
figment::Jail::expect_with(|_jail| {
let c = Config::load(None).expect("load defaults");
assert_eq!(c.server.http_port, 80);
assert_eq!(c.network.dhcp_mode, DhcpMode::Proxy);
assert_eq!(c.paths.iso_dir, PathBuf::from("/var/lib/openpxe/isos"));
Ok(())
});
} }
if let Ok(v) = std::env::var("OPENPXE_WORK_DIR") {
self.paths.work_dir = PathBuf::from(v); #[test]
fn legacy_flat_env_vars_still_apply() {
figment::Jail::expect_with(|jail| {
jail.set_env("OPENPXE_HTTP_PORT", "8123");
jail.set_env("OPENPXE_TFTP_PORT", "6900");
jail.set_env("OPENPXE_DHCP_PORT", "6767");
jail.set_env("OPENPXE_PXE_PORT", "4444");
jail.set_env("OPENPXE_PUBLIC_IP", "10.20.30.40");
jail.set_env("OPENPXE_DHCP_MODE", "disabled");
jail.set_env("OPENPXE_ISO_DIR", "/data/isos");
jail.set_env("OPENPXE_WORK_DIR", "/data/work");
jail.set_env("OPENPXE_IPXE_DIR", "/data/ipxe");
jail.set_env("OPENPXE_SMB_DIR", "/data/smb");
// v0.5.4: a field the old apply_env never covered.
jail.set_env("OPENPXE_UNATTENDED_DIR", "/data/unattended");
let c = Config::load(None).expect("load with env");
assert_eq!(c.server.http_port, 8123);
assert_eq!(c.server.tftp_port, 6900);
assert_eq!(c.network.dhcp_port, 6767);
assert_eq!(c.network.pxe_port, 4444);
assert_eq!(c.server.public_ip, Some("10.20.30.40".parse().unwrap()));
assert_eq!(c.network.dhcp_mode, DhcpMode::Disabled);
assert_eq!(c.paths.iso_dir, PathBuf::from("/data/isos"));
assert_eq!(c.paths.work_dir, PathBuf::from("/data/work"));
assert_eq!(c.paths.ipxe_dir, PathBuf::from("/data/ipxe"));
assert_eq!(c.paths.smb_dir, PathBuf::from("/data/smb"));
assert_eq!(c.paths.unattended_dir, PathBuf::from("/data/unattended"));
Ok(())
});
} }
if let Ok(v) = std::env::var("OPENPXE_IPXE_DIR") {
self.paths.ipxe_dir = PathBuf::from(v); #[test]
} fn dhcp_mode_off_alias_maps_to_disabled() {
if let Ok(v) = std::env::var("OPENPXE_SMB_DIR") { figment::Jail::expect_with(|jail| {
self.paths.smb_dir = PathBuf::from(v); jail.set_env("OPENPXE_DHCP_MODE", "off");
let c = Config::load(None).unwrap();
assert_eq!(c.network.dhcp_mode, DhcpMode::Disabled);
Ok(())
});
} }
#[test]
fn nested_double_underscore_form_also_works() {
figment::Jail::expect_with(|jail| {
jail.set_env("OPENPXE_SERVER__HTTP_PORT", "9001");
let c = Config::load(None).unwrap();
assert_eq!(c.server.http_port, 9001);
Ok(())
});
}
#[test]
fn env_overrides_toml_file() {
figment::Jail::expect_with(|jail| {
jail.create_file(
"openpxe.toml",
"[server]\nhttp_port = 8080\n[paths]\niso_dir = \"/from/toml\"\n",
)?;
jail.set_env("OPENPXE_HTTP_PORT", "8443");
let c = Config::load(Some(Path::new("openpxe.toml"))).unwrap();
// env wins over TOML…
assert_eq!(c.server.http_port, 8443);
// …but TOML-only values still apply.
assert_eq!(c.paths.iso_dir, PathBuf::from("/from/toml"));
Ok(())
});
}
#[test]
fn unrelated_openpxe_env_vars_are_ignored() {
figment::Jail::expect_with(|jail| {
// entrypoint.sh sets these; they must not break config load.
jail.set_env("OPENPXE_UID", "10001");
jail.set_env("OPENPXE_CONFIG", "/etc/openpxe.toml");
let c = Config::load(None).expect("stray vars ignored");
assert_eq!(c.server.http_port, 80);
Ok(())
});
} }
} }
+65
View File
@@ -0,0 +1,65 @@
//! Small, dependency-free encoding helpers shared across crates.
//!
//! v0.5.4: `pct_encode` and `xml_escape` were duplicated in the SAML
//! modules and the HTTP layer; they live here now. They're deliberately
//! hand-rolled rather than pulling in `percent-encoding` / `url`: the
//! unreserved set below is exactly the RFC 3986 set that iPXE's
//! `:uristring` modifier and the SAML HTTP-Redirect binding both expect,
//! and a general-purpose URL crate escapes a different set.
use std::fmt::Write as _;
/// Percent-encode `s` per RFC 3986: the unreserved set
/// (`A-Z` `a-z` `0-9` `-` `_` `.` `~`) passes through unchanged; every
/// other byte becomes `%XX` (uppercase hex).
#[must_use]
pub fn pct_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
let _ = write!(out, "%{b:02X}");
}
}
}
out
}
/// Escape the five XML predefined entities so `s` is safe inside element
/// text or a double-quoted attribute value.
#[must_use]
pub fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pct_encode_unreserved_passthrough_else_hex() {
assert_eq!(pct_encode("node-7.lab_1~"), "node-7.lab_1~");
assert_eq!(pct_encode("aa:bb cc/?&="), "aa%3Abb%20cc%2F%3F%26%3D");
assert_eq!(pct_encode(""), "");
}
#[test]
fn xml_escape_all_five_entities() {
assert_eq!(xml_escape("a&b<c>\"d'e"), "a&amp;b&lt;c&gt;&quot;d&apos;e");
assert_eq!(xml_escape("plain text"), "plain text");
}
}
+7 -2
View File
@@ -5,9 +5,12 @@
pub mod arch; pub mod arch;
pub mod auth; pub mod auth;
pub mod boot_log; pub mod boot_log;
pub mod boot_rules;
pub mod boot_tokens;
pub mod branding; pub mod branding;
pub mod client; pub mod client;
pub mod config; pub mod config;
pub mod encoding;
pub mod error; pub mod error;
pub mod host_bindings; pub mod host_bindings;
pub mod log_bus; pub mod log_bus;
@@ -20,9 +23,11 @@ pub mod settings;
pub mod sso; pub mod sso;
pub mod wol; pub mod wol;
pub use arch::{ClientArch, FirmwareClass}; pub use arch::{ClientArch, DriverMode, FirmwareClass};
pub use auth::{AdminAccount, AdminPublic, AdminStore}; pub use auth::{AdminAccount, AdminPublic, AdminStore};
pub use boot_log::{BootEvent, BootLog}; pub use boot_log::{BootEvent, BootLog};
pub use boot_rules::{BootRule, BootRulesConfig, BootRulesStore};
pub use boot_tokens::BootTokens;
pub use branding::{ext_for_mime, BrandingStore, LogoSlot, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES}; pub use branding::{ext_for_mime, BrandingStore, LogoSlot, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES};
pub use client::{ClientEvent, ClientRegistry, ClientSnapshot}; pub use client::{ClientEvent, ClientRegistry, ClientSnapshot};
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig}; pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
@@ -35,4 +40,4 @@ pub use profile::DeployProfile;
pub use queue::{DeploymentQueue, QueueEntry}; pub use queue::{DeploymentQueue, QueueEntry};
pub use saml::{IdpMetadata, SamlError, SpParams, VerifiedPrincipal, VerifiedResponse}; pub use saml::{IdpMetadata, SamlError, SpParams, VerifiedPrincipal, VerifiedResponse};
pub use settings::{Settings, SettingsStore, TimeoutAction}; pub use settings::{Settings, SettingsStore, TimeoutAction};
pub use sso::{SsoConfig, SsoStore}; pub use sso::{SsoConfig, SsoLoginInfo, SsoStore};
+4 -1
View File
@@ -303,7 +303,10 @@ mod tests {
smtp_host: "smtp.example.com".into(), smtp_host: "smtp.example.com".into(),
..Default::default() ..Default::default()
}); });
assert!(matches!(r, Err(Error::Invalid(_))), "missing recipient should reject"); assert!(
matches!(r, Err(Error::Invalid(_))),
"missing recipient should reject"
);
s.replace(NotifyConfig { s.replace(NotifyConfig {
enabled: true, enabled: true,
kind: NotifyKind::Smtp, kind: NotifyKind::Smtp,
+3 -32
View File
@@ -5,7 +5,6 @@
//! appended as the `SAMLRequest` query parameter. AuthnRequests are sent //! appended as the `SAMLRequest` query parameter. AuthnRequests are sent
//! unsigned in this release (the IdP must not require client signatures). //! unsigned in this release (the IdP must not require client signatures).
use std::fmt::Write as _;
use std::io::Write as _; use std::io::Write as _;
use base64::Engine; use base64::Engine;
@@ -15,6 +14,7 @@ use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime; use time::OffsetDateTime;
use super::{SamlError, SpParams}; use super::{SamlError, SpParams};
use crate::encoding::{pct_encode, xml_escape};
const NS_PROTOCOL: &str = "urn:oasis:names:tc:SAML:2.0:protocol"; const NS_PROTOCOL: &str = "urn:oasis:names:tc:SAML:2.0:protocol";
const NS_ASSERTION: &str = "urn:oasis:names:tc:SAML:2.0:assertion"; const NS_ASSERTION: &str = "urn:oasis:names:tc:SAML:2.0:assertion";
@@ -79,37 +79,8 @@ fn deflate_base64(xml: &str) -> Result<String, SamlError> {
Ok(base64::engine::general_purpose::STANDARD.encode(compressed)) Ok(base64::engine::general_purpose::STANDARD.encode(compressed))
} }
/// Percent-encode a query-string component (RFC 3986 unreserved set passes // `pct_encode` + `xml_escape` now live in `openpxe_core::encoding` (v0.5.4)
/// through; everything else is `%XX`). // — imported above.
fn pct_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 3);
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
let _ = write!(out, "%{b:02X}");
}
}
}
out
}
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
_ => out.push(c),
}
}
out
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
+2 -15
View File
@@ -7,6 +7,7 @@
use base64::Engine; use base64::Engine;
use super::{SamlError, SpParams}; use super::{SamlError, SpParams};
use crate::encoding::xml_escape;
/// SAML 2.0 binding URIs. /// SAML 2.0 binding URIs.
pub const BINDING_REDIRECT: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"; pub const BINDING_REDIRECT: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect";
@@ -148,21 +149,7 @@ fn node_text(n: &roxmltree::Node<'_, '_>) -> String {
.collect() .collect()
} }
/// Minimal XML attribute/text escaping for the values we interpolate. // `xml_escape` now lives in `openpxe_core::encoding` (v0.5.4) — imported above.
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
_ => out.push(c),
}
}
out
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
+30
View File
@@ -73,6 +73,23 @@ impl SsoConfig {
} }
} }
/// The minimal, non-sensitive slice of the SSO config that the **pre-auth**
/// login screen needs to render the "Sign in with …" button. Carries only
/// the display affordances — never the metadata XML/URL or entity ID, which
/// stay behind the auth-gated `/api/sso`. Served as part of the public
/// `/api/me` so the button renders reliably whether or not anyone is signed
/// in (v0.5.9: fixes the button vanishing because `/api/sso` 401s pre-auth).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SsoLoginInfo {
/// True only when SSO is *usable* (enabled AND a metadata source is
/// present) — i.e. clicking the button will actually reach an IdP.
pub enabled: bool,
/// Button label, e.g. "STC AD". Empty falls back to "SSO" in the UI.
pub idp_name: String,
/// Optional IdP logo rendered on the button. Empty = no image.
pub idp_logo_url: String,
}
/// In-memory + on-disk SSO settings registry. /// In-memory + on-disk SSO settings registry.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SsoStore { pub struct SsoStore {
@@ -111,6 +128,19 @@ impl SsoStore {
self.inner.read().clone() self.inner.read().clone()
} }
/// Public, non-sensitive descriptor for the login screen. Safe to
/// expose pre-auth — it's exactly what the "Sign in with …" button
/// keys off, with no metadata/entity-ID leakage. v0.5.9.
#[must_use]
pub fn login_info(&self) -> SsoLoginInfo {
let cfg = self.inner.read();
SsoLoginInfo {
enabled: cfg.is_usable(),
idp_name: cfg.idp_name.clone(),
idp_logo_url: cfg.idp_logo_url.clone(),
}
}
/// Replace the whole config in one shot. Light validation: metadata /// Replace the whole config in one shot. Light validation: metadata
/// XML and URL are length-capped so an operator can't OOM us by /// XML and URL are length-capped so an operator can't OOM us by
/// pasting a 10 GiB blob; the IdP UI tab clamps the input visually, /// pasting a 10 GiB blob; the IdP UI tab clamps the input visually,
+3 -5
View File
@@ -181,10 +181,7 @@ mod tests {
Ipv4Addr::new(192, 168, 1, 255) Ipv4Addr::new(192, 168, 1, 255)
); );
assert_eq!( assert_eq!(
subnet_broadcast( subnet_broadcast(Ipv4Addr::new(10, 5, 3, 7), Ipv4Addr::new(255, 255, 0, 0)),
Ipv4Addr::new(10, 5, 3, 7),
Ipv4Addr::new(255, 255, 0, 0)
),
Ipv4Addr::new(10, 5, 255, 255) Ipv4Addr::new(10, 5, 255, 255)
); );
} }
@@ -196,7 +193,8 @@ mod tests {
// and confirm send_magic transmits the exact 102-byte packet. // and confirm send_magic transmits the exact 102-byte packet.
let rx = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).unwrap(); let rx = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).unwrap();
let port = rx.local_addr().unwrap().port(); let port = rx.local_addr().unwrap().port();
rx.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap(); rx.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.unwrap();
let packet = magic_packet([0x0a, 0x1b, 0x2c, 0x3d, 0x4e, 0x5f]); let packet = magic_packet([0x0a, 0x1b, 0x2c, 0x3d, 0x4e, 0x5f]);
let sent = send_magic(&packet, &[Ipv4Addr::LOCALHOST], port).unwrap(); let sent = send_magic(&packet, &[Ipv4Addr::LOCALHOST], port).unwrap();
+6
View File
@@ -18,3 +18,9 @@ tracing.workspace = true
thiserror.workspace = true thiserror.workspace = true
anyhow.workspace = true anyhow.workspace = true
bytes.workspace = true bytes.workspace = true
parking_lot.workspace = true
# v0.7.1: learned driver modes persist to <work_dir>/driver_modes.json.
serde_json.workspace = true
[dev-dependencies]
tempfile = "3.12"
+494
View File
@@ -0,0 +1,494 @@
//! Automatic per-MAC boot-binary escalation (v0.6.1, extended v0.7.x).
//!
//! OpenPXE serves the firmware-net iPXE build (`snponly`/`undionly`) by
//! default — it's the most reliable choice for chainloading because the
//! firmware just proved its network works by downloading the NBP. Two
//! classes of machine can't run it:
//!
//! * a minority of NICs have a missing or buggy firmware UNDI/SNP stack —
//! they TFTP the binary fine but iPXE can't bring the link up;
//! * Secure-Boot firmware downloads it fine but refuses to *execute* an
//! unsigned image.
//!
//! Both look identical from here: the tell-tale second DHCP DISCOVER
//! carrying the `iPXE` user-class never arrives and the machine
//! re-PXE-boots. So a fresh firmware DISCOVER from a MAC whose previous
//! attempt was never confirmed climbs one rung:
//! `Firmware → Builtin → Shim` (the signed shim+GRUB chain). The decision
//! is sticky; there is no operator toggle; the default path is unchanged
//! so hardware that already boots never regresses.
//!
//! v0.7.1 — **learned modes persist**. Walking the ladder costs one or
//! two failed boot cycles, so a machine should pay it once *ever*, not
//! once per idle window or server restart. Two events pin a MAC's mode
//! to disk (`<work_dir>/driver_modes.json`):
//!
//! * a confirmed iPXE handoff at a non-default mode (Builtin proved to
//! work — also Shim, via the GRUB→iPXE same-boot chainload);
//! * reaching the terminal Shim rung (Secure-Boot machines never produce
//! an iPXE handoff from the signed menu, so escalation itself is the
//! best knowledge we'll ever have).
//!
//! Pinned entries are immune to the TTL and reload at startup. The
//! operator escape hatch is a rules-level driver-mode pin (which
//! overrides this table entirely) or deleting `driver_modes.json`.
use openpxe_core::DriverMode;
use parking_lot::Mutex;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
/// Multiple DISCOVERs within this window belong to the *same* boot (DHCP
/// retransmits, plus the :4011 PXE Boot Server query that follows the :67
/// DISCOVER). They must not be mistaken for a failed-and-retried boot.
const SAME_BOOT_DEBOUNCE: Duration = Duration::from_secs(8);
/// Forget an *unpinned* MAC's state after this long with no activity, so
/// a transient mid-walk state doesn't linger and the map stays bounded.
/// Pinned (learned) entries are exempt — that's their whole point.
const ENTRY_TTL: Duration = Duration::from_mins(30);
/// Hard cap on tracked MACs. Past this we evict the least-recently-seen
/// entry (unpinned first) — escalation is best-effort, never a
/// memory-growth vector.
const MAX_ENTRIES: usize = 4096;
/// How often (at most) the whole map is swept for expired entries.
/// Correctness doesn't depend on the sweep — a stale entry is also
/// detected inline when its MAC next appears — so the sweep only bounds
/// memory for MACs that never return, and amortizing it keeps the
/// per-packet path O(1) instead of O(map).
const PRUNE_INTERVAL: Duration = Duration::from_mins(1);
#[derive(Debug, Clone, Copy)]
struct Entry {
mode: DriverMode,
/// True once we've served `mode` and are waiting for the iPXE handoff to
/// confirm it worked. A *new* boot arriving while this is still true means
/// the previous attempt failed and we should escalate.
awaiting_confirm: bool,
/// Learned mode (v0.7.1): persisted to disk, exempt from the TTL.
pinned: bool,
last_seen: Instant,
}
#[derive(Debug)]
struct Inner {
map: HashMap<String, Entry>,
/// When the last full TTL sweep ran — see [`PRUNE_INTERVAL`].
last_prune: Instant,
}
impl Default for Inner {
fn default() -> Self {
Self {
map: HashMap::new(),
last_prune: Instant::now(),
}
}
}
/// Tracks per-MAC driver-mode escalation. Cheap to share via `Arc`.
#[derive(Debug, Default)]
pub struct DriverEscalation {
inner: Mutex<Inner>,
/// Persistence target for learned modes; `None` = ephemeral (tests).
path: Option<Arc<PathBuf>>,
}
impl DriverEscalation {
/// Ephemeral instance (no persistence) — used by tests.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Instance backed by `<work_dir>/driver_modes.json`. Learned modes
/// from previous runs are reloaded as pinned entries; a missing or
/// corrupt file starts empty (same crash-cache policy as every other
/// store — a bad file must never block PXE).
#[must_use]
pub fn load_or_default(work_dir: &Path) -> Self {
let path = work_dir.join("driver_modes.json");
let mut map = HashMap::new();
if let Ok(text) = std::fs::read_to_string(&path) {
match serde_json::from_str::<HashMap<String, DriverMode>>(&text) {
Ok(loaded) => {
let now = Instant::now();
for (mac, mode) in loaded {
// Firmware is the default — persisting it would be
// noise; tolerate it in the file but don't track it.
if mode == DriverMode::Firmware {
continue;
}
map.insert(
mac,
Entry {
mode,
awaiting_confirm: false,
pinned: true,
last_seen: now,
},
);
}
tracing::info!(
target: "openpxe::dhcp",
learned = map.len(),
"loaded learned driver modes"
);
}
Err(e) => {
tracing::warn!(
target: "openpxe::dhcp",
"driver_modes.json present but unreadable ({e}); starting empty"
);
}
}
}
Self {
inner: Mutex::new(Inner {
map,
last_prune: Instant::now(),
}),
path: Some(Arc::new(path)),
}
}
/// Decide the driver mode for a firmware (PXEClient/HTTPClient) boot from
/// `mac`. `primary` is true for the main DHCP DISCOVER (:67) and false for
/// the PXE Boot Server query (:4011); only the primary path drives
/// escalation, and only when it's clearly a *new* boot (outside the
/// same-boot debounce). The :4011 path just echoes the current mode.
pub fn mode_for_firmware_attempt(&self, mac: &str, primary: bool) -> DriverMode {
self.decide_at(mac, primary, Instant::now())
}
/// Record that `mac` completed the iPXE handoff (a DISCOVER carrying the
/// `iPXE` user-class). The mode we last served worked, so stop awaiting
/// confirmation, keep it sticky, and — for non-default modes — pin it to
/// disk so the machine never re-walks the ladder (v0.7.1).
pub fn mark_ipxe_success(&self, mac: &str) {
self.confirm_at(mac, Instant::now());
}
fn decide_at(&self, mac: &str, primary: bool, now: Instant) -> DriverMode {
let (mode, snapshot) = {
let mut g = self.inner.lock();
if now.duration_since(g.last_prune) >= PRUNE_INTERVAL {
g.map
.retain(|_, e| e.pinned || now.duration_since(e.last_seen) < ENTRY_TTL);
g.last_prune = now;
}
// Inline staleness check: an unpinned MAC whose entry outlived
// the TTL starts fresh even when the amortized sweep above
// hasn't caught it yet. Pinned entries never go stale.
if g.map
.get(mac)
.is_some_and(|e| !e.pinned && now.duration_since(e.last_seen) >= ENTRY_TTL)
{
g.map.remove(mac);
}
let mut newly_pinned = false;
let mode = match g.map.get_mut(mac) {
None => {
g.map.insert(
mac.to_owned(),
Entry {
mode: DriverMode::Firmware,
// Only the primary DISCOVER opens a confirmation window.
awaiting_confirm: primary,
pinned: false,
last_seen: now,
},
);
if g.map.len() > MAX_ENTRIES {
evict_oldest(&mut g.map);
}
DriverMode::Firmware
}
Some(entry) => {
let recent = now.duration_since(entry.last_seen) < SAME_BOOT_DEBOUNCE;
if primary && !recent {
// A genuinely new boot. If the previous attempt was
// never confirmed, the build we served failed → climb
// one rung: Firmware (firmware NIC stack) → Builtin
// (iPXE's own drivers) → Shim (signed shim+GRUB —
// covers Secure Boot firmware that downloads our
// unsigned iPXE but refuses to execute it). Shim is
// terminal and pins to disk: SB machines never emit
// an iPXE handoff from the signed menu, so reaching
// the rung *is* the durable knowledge.
if entry.awaiting_confirm {
entry.mode = match entry.mode {
DriverMode::Firmware => DriverMode::Builtin,
DriverMode::Builtin | DriverMode::Shim => DriverMode::Shim,
};
if entry.mode == DriverMode::Shim && !entry.pinned {
entry.pinned = true;
newly_pinned = true;
}
}
entry.awaiting_confirm = true;
}
entry.last_seen = now;
entry.mode
}
};
(mode, newly_pinned.then(|| pinned_snapshot(&g.map)))
};
if let Some(s) = snapshot {
self.persist(&s);
}
mode
}
fn confirm_at(&self, mac: &str, now: Instant) {
let snapshot = {
let mut g = self.inner.lock();
let Some(e) = g.map.get_mut(mac) else {
return;
};
e.awaiting_confirm = false;
e.last_seen = now;
// A proven non-default mode is worth remembering forever —
// the machine demonstrably can't use the default path.
if e.mode != DriverMode::Firmware && !e.pinned {
e.pinned = true;
Some(pinned_snapshot(&g.map))
} else {
None
}
};
if let Some(s) = snapshot {
self.persist(&s);
}
}
/// Best-effort atomic write of the learned-mode table. No-op for
/// ephemeral instances. Failure logs and moves on — persistence is an
/// optimization, never a correctness requirement.
fn persist(&self, snapshot: &HashMap<String, DriverMode>) {
let Some(path) = &self.path else { return };
let body = match serde_json::to_vec_pretty(snapshot) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::dhcp", "serialize driver_modes.json: {e}");
return;
}
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::dhcp", "write driver_modes.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, path.as_path()) {
tracing::warn!(target: "openpxe::dhcp", "rename driver_modes.json: {e}");
}
}
}
fn pinned_snapshot(map: &HashMap<String, Entry>) -> HashMap<String, DriverMode> {
map.iter()
.filter(|(_, e)| e.pinned)
.map(|(k, e)| (k.clone(), e.mode))
.collect()
}
fn evict_oldest(map: &mut HashMap<String, Entry>) {
// Prefer evicting an unpinned entry; only touch learned modes when
// the whole table is pinned (4096 learned machines — at that point
// the operator has bigger questions than our memory bound).
let pick = |pinned: bool| {
map.iter()
.filter(|(_, e)| e.pinned == pinned)
.min_by_key(|(_, e)| e.last_seen)
.map(|(k, _)| k.clone())
};
if let Some(oldest) = pick(false).or_else(|| pick(true)) {
map.remove(&oldest);
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn firmware_first_then_escalates_on_unconfirmed_retry() {
let e = DriverEscalation::new();
let t0 = Instant::now();
// Boot 1, primary DISCOVER: firmware.
assert_eq!(e.decide_at("aa", true, t0), DriverMode::Firmware);
// Same boot's :4011 query (+1s, within debounce): still firmware, no escalation.
assert_eq!(
e.decide_at("aa", false, t0 + Duration::from_secs(1)),
DriverMode::Firmware
);
// Firmware net failed → no iPXE handoff → machine re-PXE-boots much
// later: escalate to builtin drivers.
assert_eq!(
e.decide_at("aa", true, t0 + Duration::from_mins(1)),
DriverMode::Builtin
);
}
#[test]
fn builtin_is_sticky_after_success() {
let e = DriverEscalation::new();
let t0 = Instant::now();
assert_eq!(e.decide_at("bb", true, t0), DriverMode::Firmware);
assert_eq!(
e.decide_at("bb", true, t0 + Duration::from_mins(1)),
DriverMode::Builtin
);
// Builtin worked this time — confirm the handoff.
e.confirm_at("bb", t0 + Duration::from_secs(61));
// Next cold boot goes straight to builtin (no wasted firmware attempt).
assert_eq!(
e.decide_at("bb", true, t0 + Duration::from_mins(2)),
DriverMode::Builtin
);
}
#[test]
fn confirmed_firmware_never_escalates() {
let e = DriverEscalation::new();
let t0 = Instant::now();
assert_eq!(e.decide_at("cc", true, t0), DriverMode::Firmware);
// snponly worked: handoff confirmed.
e.confirm_at("cc", t0 + Duration::from_secs(2));
// A later boot stays on firmware — no spurious escalation.
assert_eq!(
e.decide_at("cc", true, t0 + Duration::from_mins(5)),
DriverMode::Firmware
);
}
#[test]
fn third_unconfirmed_attempt_escalates_to_shim_and_stays() {
// v0.7.0: a Secure-Boot client downloads-but-refuses both unsigned
// iPXE builds; the third boot gets the signed shim chain, and the
// MAC stays there for subsequent boots.
let e = DriverEscalation::new();
let t0 = Instant::now();
assert_eq!(e.decide_at("ee", true, t0), DriverMode::Firmware);
assert_eq!(
e.decide_at("ee", true, t0 + Duration::from_mins(1)),
DriverMode::Builtin
);
assert_eq!(
e.decide_at("ee", true, t0 + Duration::from_mins(2)),
DriverMode::Shim
);
// Shim is terminal — a fourth unconfirmed boot stays on Shim.
assert_eq!(
e.decide_at("ee", true, t0 + Duration::from_mins(3)),
DriverMode::Shim
);
}
#[test]
fn stale_unpinned_entry_is_forgotten_and_resets_to_firmware() {
let e = DriverEscalation::new();
let t0 = Instant::now();
assert_eq!(e.decide_at("dd", true, t0), DriverMode::Firmware);
assert_eq!(
e.decide_at("dd", true, t0 + Duration::from_mins(1)),
DriverMode::Builtin
);
// After the TTL with no activity the (unpinned) Builtin walk is
// pruned → fresh firmware. (A *confirmed* Builtin would be pinned
// and survive — see learned_builtin_survives_ttl.)
let later = t0 + Duration::from_mins(1) + ENTRY_TTL + Duration::from_secs(1);
assert_eq!(e.decide_at("dd", true, later), DriverMode::Firmware);
}
#[test]
fn shim_pin_survives_ttl() {
// v0.7.1: reaching the Shim rung is durable knowledge — the
// machine must NOT re-walk the ladder after an idle period.
let e = DriverEscalation::new();
let t0 = Instant::now();
let _ = e.decide_at("ff", true, t0);
let _ = e.decide_at("ff", true, t0 + Duration::from_mins(1));
assert_eq!(
e.decide_at("ff", true, t0 + Duration::from_mins(2)),
DriverMode::Shim
);
let much_later = t0 + Duration::from_mins(2) + ENTRY_TTL + Duration::from_mins(5);
assert_eq!(e.decide_at("ff", true, much_later), DriverMode::Shim);
}
#[test]
fn learned_builtin_survives_ttl() {
let e = DriverEscalation::new();
let t0 = Instant::now();
let _ = e.decide_at("gg", true, t0);
assert_eq!(
e.decide_at("gg", true, t0 + Duration::from_mins(1)),
DriverMode::Builtin
);
// The handoff confirms Builtin → pinned.
e.confirm_at("gg", t0 + Duration::from_secs(61));
let much_later = t0 + ENTRY_TTL + Duration::from_mins(10);
assert_eq!(e.decide_at("gg", true, much_later), DriverMode::Builtin);
}
#[test]
fn learned_modes_persist_across_restart() {
let dir = tempdir().unwrap();
let t0 = Instant::now();
{
let e = DriverEscalation::load_or_default(dir.path());
// Walk one MAC to Shim (pins on escalation)...
let _ = e.decide_at("aa:01", true, t0);
let _ = e.decide_at("aa:01", true, t0 + Duration::from_mins(1));
assert_eq!(
e.decide_at("aa:01", true, t0 + Duration::from_mins(2)),
DriverMode::Shim
);
// ...and another to a confirmed Builtin (pins on handoff).
let _ = e.decide_at("aa:02", true, t0);
let _ = e.decide_at("aa:02", true, t0 + Duration::from_mins(1));
e.confirm_at("aa:02", t0 + Duration::from_secs(61));
}
// "Restart": a fresh instance from the same work_dir knows both.
let e2 = DriverEscalation::load_or_default(dir.path());
assert_eq!(e2.decide_at("aa:01", true, t0), DriverMode::Shim);
assert_eq!(e2.decide_at("aa:02", true, t0), DriverMode::Builtin);
// Unlearned MACs still start at the default.
assert_eq!(e2.decide_at("aa:03", true, t0), DriverMode::Firmware);
}
#[test]
fn corrupt_persistence_file_starts_empty() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("driver_modes.json"), b"{broken").unwrap();
let e = DriverEscalation::load_or_default(dir.path());
assert_eq!(
e.decide_at("aa:bb", true, Instant::now()),
DriverMode::Firmware
);
}
#[test]
fn confirmed_firmware_is_not_persisted() {
// The default mode is never written — the file only carries
// exceptions, so a healthy fleet leaves it absent/empty.
let dir = tempdir().unwrap();
let t0 = Instant::now();
{
let e = DriverEscalation::load_or_default(dir.path());
let _ = e.decide_at("aa:09", true, t0);
e.confirm_at("aa:09", t0 + Duration::from_secs(2));
}
assert!(!dir.path().join("driver_modes.json").exists());
}
}
+2
View File
@@ -17,7 +17,9 @@
//! clients silently drop them. //! clients silently drop them.
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
pub mod escalation;
pub mod reply; pub mod reply;
pub mod server; pub mod server;
pub use escalation::DriverEscalation;
pub use server::DhcpProxyServer; pub use server::DhcpProxyServer;
+23 -8
View File
@@ -9,7 +9,7 @@
//! pass, or the HTTP URL of the boot script once iPXE has chained. //! pass, or the HTTP URL of the boot script once iPXE has chained.
use dhcproto::v4::{DhcpOption, Message, MessageType, Opcode, OptionCode}; use dhcproto::v4::{DhcpOption, Message, MessageType, Opcode, OptionCode};
use openpxe_core::{ClientArch, FirmwareClass}; use openpxe_core::{ClientArch, DriverMode, FirmwareClass};
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
/// Where the reply directs the client next. /// Where the reply directs the client next.
@@ -31,6 +31,11 @@ pub struct ReplyContext<'a> {
pub our_ip: Ipv4Addr, pub our_ip: Ipv4Addr,
pub arch: ClientArch, pub arch: ClientArch,
pub class: FirmwareClass, pub class: FirmwareClass,
/// Which iPXE network backend to advertise for this client. The DHCP
/// proxy fills this from the automatic per-MAC escalation state: normally
/// [`DriverMode::Firmware`], escalated to [`DriverMode::Builtin`] for a
/// MAC whose firmware-net boot failed to chainload (v0.6.1).
pub driver_mode: DriverMode,
/// Public base URL (scheme://host[:port]) used in HTTP directives. /// Public base URL (scheme://host[:port]) used in HTTP directives.
pub public_base_url: &'a str, pub public_base_url: &'a str,
} }
@@ -44,17 +49,27 @@ pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
// Pass the client's MAC in the query string so the HTTP // Pass the client's MAC in the query string so the HTTP
// layer can short-circuit to a per-MAC binding when one // layer can short-circuit to a per-MAC binding when one
// exists. iPXE substitutes `${mac}` literally before issuing // exists. iPXE substitutes `${mac}` literally before issuing
// the GET, so this stays static across firmwares. // the GET, so this stays static across firmwares. The arch is
// known *here* from option 93, so it's baked in literally
// (v0.7.0) — it lets boot rules select on architecture.
url: format!( url: format!(
"{}/boot.ipxe?mac=${{mac}}", "{}/boot.ipxe?mac=${{mac}}&arch={}",
ctx.public_base_url.trim_end_matches('/') ctx.public_base_url.trim_end_matches('/'),
ctx.arch.as_str()
), ),
}, },
FirmwareClass::HttpClient => { FirmwareClass::HttpClient => {
// UEFI HTTP boot: client wants an http:// URL in option 67 // UEFI HTTP boot: client wants an http:// URL in option 67
// pointing at an EFI executable. We serve ipxe.efi over HTTP; // pointing at an EFI executable. We serve the iPXE EFI build for
// it'll then do the same script-fetch the iPXE path does. // the negotiated driver mode over HTTP; it'll then do the same
let name = ctx.arch.ipxe_bootfile().unwrap_or("snponly.efi"); // script-fetch the iPXE path does.
// `bootfile_with_fallback` (v0.7.0) walks back down the
// escalation ladder when the negotiated mode has no binary
// for this arch (e.g. Shim on an arch with no signed chain).
let name = ctx
.arch
.bootfile_with_fallback(ctx.driver_mode)
.unwrap_or("snponly.efi");
BootDirective::HttpScript { BootDirective::HttpScript {
url: format!( url: format!(
"{}/ipxe/{}", "{}/ipxe/{}",
@@ -63,7 +78,7 @@ pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
), ),
} }
} }
FirmwareClass::PxeClient => match ctx.arch.ipxe_bootfile() { FirmwareClass::PxeClient => match ctx.arch.bootfile_with_fallback(ctx.driver_mode) {
Some(name) => BootDirective::TftpIpxe { Some(name) => BootDirective::TftpIpxe {
filename: name.to_string(), filename: name.to_string(),
}, },
+56 -7
View File
@@ -1,10 +1,13 @@
//! UDP listener loop for the DHCP proxy. Accepts on :67 (and :4011 on a //! UDP listener loop for the DHCP proxy. Accepts on :67 (and :4011 on a
//! second socket) and dispatches each datagram through the pure reply logic. //! second socket) and dispatches each datagram through the pure reply logic.
use crate::escalation::DriverEscalation;
use crate::reply::{build_reply, decide, BootDirective, ReplyContext}; use crate::reply::{build_reply, decide, BootDirective, ReplyContext};
use dhcproto::v4::{DhcpOption, Message, OptionCode}; use dhcproto::v4::{DhcpOption, Message, OptionCode};
use dhcproto::{Decodable, Decoder, Encodable, Encoder}; use dhcproto::{Decodable, Decoder, Encodable, Encoder};
use openpxe_core::{ClientArch, ClientEvent, ClientRegistry, FirmwareClass}; use openpxe_core::{
BootRulesStore, ClientArch, ClientEvent, ClientRegistry, DriverMode, FirmwareClass,
};
use socket2::{Domain, Protocol, Socket, Type}; use socket2::{Domain, Protocol, Socket, Type};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use std::sync::Arc; use std::sync::Arc;
@@ -18,9 +21,19 @@ pub struct DhcpProxyServer {
public_base_url: String, public_base_url: String,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics, metrics: openpxe_core::Metrics,
/// Automatic per-MAC NIC driver-mode escalation (v0.6.1; persistent
/// learned modes since v0.7.1). Shared across the :67 and :4011
/// listener tasks via the server `Arc`. Built by the caller so the
/// persistence path comes from the configured work dir.
escalation: DriverEscalation,
/// v0.7.1: boot rules — consulted for an operator driver-mode pin
/// (e.g. "this OUI is all Secure Boot → serve shim immediately")
/// before the automatic escalation ladder.
rules: BootRulesStore,
} }
impl DhcpProxyServer { impl DhcpProxyServer {
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
bind: IpAddr, bind: IpAddr,
dhcp_port: u16, dhcp_port: u16,
@@ -29,6 +42,8 @@ impl DhcpProxyServer {
public_base_url: String, public_base_url: String,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics, metrics: openpxe_core::Metrics,
escalation: DriverEscalation,
rules: BootRulesStore,
) -> Self { ) -> Self {
Self { Self {
bind, bind,
@@ -38,6 +53,8 @@ impl DhcpProxyServer {
public_base_url, public_base_url,
clients, clients,
metrics, metrics,
escalation,
rules,
} }
} }
@@ -126,11 +143,38 @@ impl DhcpProxyServer {
}, },
); );
// Automatic NIC driver-mode selection (v0.6.1). The default is
// firmware-net (snponly/undionly). A successful iPXE handoff confirms
// the current mode works for this MAC; a fresh firmware boot whose
// predecessor never handed off escalates the MAC to iPXE's built-in
// NIC drivers. No operator toggle — the firmware path is unchanged so
// hardware that already boots never regresses.
let driver_mode = match class {
FirmwareClass::IpxeUserClass => {
self.escalation.mark_ipxe_success(&mac);
DriverMode::Firmware // unused: this path serves the HTTP script
}
FirmwareClass::PxeClient | FirmwareClass::HttpClient => {
// v0.7.1: an operator rule pin wins over (and bypasses)
// the automatic escalation ladder — known Secure-Boot
// fleets boot the signed chain on the very first cycle.
if let Some(pinned) = self.rules.driver_mode_hint(&mac, Some(arch.as_str())) {
pinned
} else {
self.escalation
.mode_for_firmware_attempt(&mac, label == "67")
}
}
// Unreachable: FirmwareClass::Other returned above.
FirmwareClass::Other => DriverMode::Firmware,
};
let ctx = ReplyContext { let ctx = ReplyContext {
request: &request, request: &request,
our_ip: self.our_ip, our_ip: self.our_ip,
arch, arch,
class, class,
driver_mode,
public_base_url: &self.public_base_url, public_base_url: &self.public_base_url,
}; };
let directive = decide(&ctx); let directive = decide(&ctx);
@@ -154,7 +198,7 @@ impl DhcpProxyServer {
sock.send_to(&out, dest).await?; sock.send_to(&out, dest).await?;
tracing::info!( tracing::info!(
target: "openpxe::dhcp", target: "openpxe::dhcp",
mac=%mac, arch=arch.as_str(), class=?class, dest=%dest, directive=?directive, mac=%mac, arch=arch.as_str(), class=?class, driver=?driver_mode, dest=%dest, directive=?directive,
"PXE reply sent" "PXE reply sent"
); );
Ok(()) Ok(())
@@ -213,11 +257,16 @@ fn bind_udp(bind: IpAddr, port: u16, broadcast: bool) -> anyhow::Result<UdpSocke
} }
fn format_mac(chaddr: &[u8]) -> String { fn format_mac(chaddr: &[u8]) -> String {
let take = chaddr.iter().take(6).copied().collect::<Vec<_>>(); use std::fmt::Write;
take.iter() // One allocation — this runs for every PXE datagram we answer.
.map(|b| format!("{b:02x}")) let mut s = String::with_capacity(17);
.collect::<Vec<_>>() for (i, b) in chaddr.iter().take(6).enumerate() {
.join(":") if i > 0 {
s.push(':');
}
let _ = write!(s, "{b:02x}");
}
s
} }
/// Walk raw DHCP options looking for option 93 (Client System Architecture) /// Walk raw DHCP options looking for option 93 (Client System Architecture)
+6 -2
View File
@@ -33,8 +33,6 @@ thiserror.workspace = true
anyhow.workspace = true anyhow.workspace = true
bytes.workspace = true bytes.workspace = true
futures.workspace = true futures.workspace = true
mime.workspace = true
mime_guess.workspace = true
uuid.workspace = true uuid.workspace = true
# v0.4.5 Forms auth: lock-free session store and cookie helpers. # v0.4.5 Forms auth: lock-free session store and cookie helpers.
parking_lot.workspace = true parking_lot.workspace = true
@@ -61,3 +59,9 @@ image = { version = "0.25", default-features = false, features = ["png"] }
# replay, and IdP-initiated-gating flows exercise real signatures. # replay, and IdP-initiated-gating flows exercise real signatures.
rcgen = "0.13" rcgen = "0.13"
bergshamra = { workspace = true } bergshamra = { workspace = true }
# v0.5.4: snapshot the generated iPXE menu so any unintended drift (a
# dropped line, reordered item) is caught and reviewed, not silently shipped.
insta = "1.40"
# v0.5.4: stand up a mock HTTP server to exercise the SAML metadata-URL
# fetch path (previously untested because it did a real network GET).
wiremock = "0.6"
+616 -170
View File
File diff suppressed because it is too large Load Diff
+46 -6
View File
@@ -154,16 +154,28 @@ pub fn session_cookie(session: &str) -> String {
fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> { fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
// `Cookie: a=b; c=d` parsing — small enough not to drag in a crate. // `Cookie: a=b; c=d` parsing — small enough not to drag in a crate.
// Two-step strip (name, then '=') keeps this allocation-free per
// candidate and can't match a longer cookie name sharing the prefix.
let raw = headers.get(header::COOKIE)?.to_str().ok()?; let raw = headers.get(header::COOKIE)?.to_str().ok()?;
for part in raw.split(';') { for part in raw.split(';') {
let part = part.trim(); let part = part.trim();
if let Some(v) = part.strip_prefix(&format!("{SESSION_COOKIE}=")) { if let Some(v) = part
.strip_prefix(SESSION_COOKIE)
.and_then(|rest| rest.strip_prefix('='))
{
return Some(v.to_string()); return Some(v.to_string());
} }
} }
None None
} }
/// Does this request carry a live operator session? Used by endpoints
/// outside the `/api/*` middleware that still want to honor a logged-in
/// operator (e.g. browser-testing a token-gated answer file, v0.7.0).
pub(crate) fn session_authenticated(state: &AppState, headers: &axum::http::HeaderMap) -> bool {
parse_cookie(headers).is_some_and(|t| state.sessions.touch(&t).is_some())
}
// ── Middleware ──────────────────────────────────────────────────────────── // ── Middleware ────────────────────────────────────────────────────────────
/// Return `true` if `path` is on the allowlist and should bypass the /// Return `true` if `path` is on the allowlist and should bypass the
@@ -246,7 +258,14 @@ pub async fn api_setup(State(state): State<AppState>, Json(body): Json<SetupBody
) )
.into_response(); .into_response();
} }
match state.admin.bootstrap(&body.username, &body.password) { // bcrypt hashing is ~100-200 ms of pure CPU (and `bootstrap` also
// persists to disk synchronously) — keep it off the async workers.
let admin = state.admin.clone();
let result =
tokio::task::spawn_blocking(move || admin.bootstrap(&body.username, &body.password))
.await
.unwrap_or_else(|e| Err(openpxe_core::Error::Other(e.into())));
match result {
Ok(pub_) => { Ok(pub_) => {
let session = state.sessions.create(&pub_.username); let session = state.sessions.create(&pub_.username);
login_response(StatusCode::CREATED, &pub_, &session) login_response(StatusCode::CREATED, &pub_, &session)
@@ -271,8 +290,13 @@ pub struct LoginBody {
pub async fn api_login(State(state): State<AppState>, Json(body): Json<LoginBody>) -> Response { pub async fn api_login(State(state): State<AppState>, Json(body): Json<LoginBody>) -> Response {
// Brief, deliberately vague — "invalid credentials" rather than // Brief, deliberately vague — "invalid credentials" rather than
// "no such user" / "wrong password". Same anti-enumeration posture // "no such user" / "wrong password". Same anti-enumeration posture
// as Sonarr/Radarr. // as Sonarr/Radarr. The bcrypt verify is ~100-200 ms of pure CPU on
let pub_ = match state.admin.verify(&body.username, &body.password) { // an unauthenticated endpoint, so it runs on the blocking pool.
let admin = state.admin.clone();
let verdict = tokio::task::spawn_blocking(move || admin.verify(&body.username, &body.password))
.await
.unwrap_or_else(|e| Err(openpxe_core::Error::Other(e.into())));
let pub_ = match verdict {
Ok(Some(u)) => u, Ok(Some(u)) => u,
Ok(None) => { Ok(None) => {
return ( return (
@@ -319,6 +343,12 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
// and the logo asset is public, so this leaks nothing sensitive. // and the logo asset is public, so this leaks nothing sensitive.
let has_custom_logo = state.branding.has_any_web_logo(); let has_custom_logo = state.branding.has_any_web_logo();
let logo_rev = state.branding.logo_rev(); let logo_rev = state.branding.logo_rev();
// v0.5.9: ship the non-sensitive SSO descriptor with every /api/me so
// the pre-auth login screen can render the "Sign in with …" button
// reliably. Previously the button keyed off the auth-gated /api/sso,
// which 401s when logged out — the button only survived on a stale
// in-memory config and vanished on any fresh login-page load.
let sso = state.sso.login_info();
if !state.admin.is_configured() { if !state.admin.is_configured() {
return ( return (
StatusCode::OK, StatusCode::OK,
@@ -327,6 +357,7 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
"authenticated": false, "authenticated": false,
"has_custom_logo": has_custom_logo, "has_custom_logo": has_custom_logo,
"logo_rev": logo_rev, "logo_rev": logo_rev,
"sso": sso,
})), })),
) )
.into_response(); .into_response();
@@ -343,6 +374,7 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
"session_user": u, "session_user": u,
"has_custom_logo": has_custom_logo, "has_custom_logo": has_custom_logo,
"logo_rev": logo_rev, "logo_rev": logo_rev,
"sso": sso,
})), })),
) )
.into_response(), .into_response(),
@@ -353,6 +385,7 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
"authenticated": false, "authenticated": false,
"has_custom_logo": has_custom_logo, "has_custom_logo": has_custom_logo,
"logo_rev": logo_rev, "logo_rev": logo_rev,
"sso": sso,
})), })),
) )
.into_response(), .into_response(),
@@ -385,11 +418,18 @@ pub async fn api_update_credentials(
) )
.into_response(); .into_response();
} }
let result = state.admin.update_credentials( // Two bcrypt operations (verify current + hash new) plus a sync disk
// persist — run the lot on the blocking pool.
let admin = state.admin.clone();
let result = tokio::task::spawn_blocking(move || {
admin.update_credentials(
&body.current_password, &body.current_password,
body.new_username.as_deref(), body.new_username.as_deref(),
body.new_password.as_deref(), body.new_password.as_deref(),
); )
})
.await
.unwrap_or_else(|e| Err(openpxe_core::Error::Other(e.into())));
match result { match result {
Ok(pub_) => { Ok(pub_) => {
state.sessions.revoke_all(); state.sessions.revoke_all();
+92
View File
@@ -0,0 +1,92 @@
//! Uniform HTTP error mapping for the API layer (v0.5.4).
//!
//! Before this, ~40 handlers in `app.rs` hand-wrote
//! `match … { Err(e) => (StatusCode::…, format!("{e}")).into_response() }`,
//! and the `openpxe_core::Error` → status mapping drifted between them
//! (e.g. `Invalid` → 400 in most places, 404 in one). [`AppError`] wraps
//! `openpxe_core::Error` so a handler can return `Result<T, AppError>` and
//! `?` its way out, getting one consistent status + body. The body stays
//! plain-text (matching the previous `(StatusCode, String)` responses) so
//! existing clients and tests see no shape change; 5xx detail is logged
//! and returned verbatim exactly as before.
//!
//! Handlers with *intentional* domain-specific statuses (e.g. a duplicate
//! share → 409, a still-open chunked upload → 409) keep their explicit
//! returns — `AppError` is for the common case, not a straitjacket.
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use openpxe_core::Error as CoreError;
/// Newtype over [`openpxe_core::Error`] with a uniform [`IntoResponse`].
#[derive(Debug)]
pub struct AppError(pub CoreError);
impl From<CoreError> for AppError {
fn from(e: CoreError) -> Self {
AppError(e)
}
}
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
AppError(CoreError::Io(e))
}
}
impl AppError {
/// The HTTP status this error maps to. Public so handlers (and tests)
/// can reason about the mapping in one place.
#[must_use]
pub fn status(&self) -> StatusCode {
match self.0 {
CoreError::NotFound(_) => StatusCode::NOT_FOUND,
CoreError::Invalid(_) => StatusCode::BAD_REQUEST,
CoreError::Config(_) | CoreError::Io(_) | CoreError::Other(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = self.status();
// Match the prior hand-written responses: the 4xx arms returned the
// bare inner message (not the `Display` prefix), so a UI showing
// `await r.text()` reads "metadata too long", not "invalid input:
// metadata too long". 5xx keeps the full `Display` string.
let body = match &self.0 {
CoreError::Invalid(m) | CoreError::NotFound(m) => m.clone(),
other => other.to_string(),
};
if status.is_server_error() {
// Log the full detail server-side; the body still carries it
// (unchanged from the prior `format!("{e}")` behaviour), but the
// log line is what an operator greps for.
tracing::error!(target: "openpxe::http", error = %self.0, "request failed");
}
(status, body).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_mapping_is_consistent() {
assert_eq!(
AppError(CoreError::NotFound("x".into())).status(),
StatusCode::NOT_FOUND
);
assert_eq!(
AppError(CoreError::Invalid("x".into())).status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
AppError(CoreError::Config("x".into())).status(),
StatusCode::INTERNAL_SERVER_ERROR
);
}
}
+192
View File
@@ -0,0 +1,192 @@
//! GRUB menu rendering for the Secure Boot chain (v0.7.0).
//!
//! Secure-Boot-enabled firmware refuses our unsigned iPXE, so those
//! clients are automatically escalated (see `openpxe_dhcp_proxy::
//! escalation`) to the Microsoft-signed Fedora `shim` → signed `grub`
//! chain. GRUB then fetches `grub.cfg` from this server (TFTP `$prefix`
//! resolution, or HTTP when the whole chain came over HTTP Boot) — and
//! this module renders that config from the same boot-entry model that
//! renders `boot.ipxe`.
//!
//! Scope: **Linux kernel entries only.** A signed GRUB will only execute
//! kernels that pass shim verification — i.e. distro-signed kernels —
//! which is exactly what `LinuxKernel` boot entries point at. `sanboot`
//! ISO emulation and `wimboot` are iPXE mechanisms with no signed
//! equivalent; those entries are omitted here, and the menu says so.
//! (Windows deployment under Secure Boot has no legitimate unsigned
//! path — per project policy we never ship test-signed binaries or touch
//! client trust stores.)
//!
//! The kernel/initrd lines use GRUB's `(http,host:port)` device syntax;
//! Fedora's signed netboot GRUB carries the `http`, `tftp` and `efinet`
//! modules built in, so no unsigned module loading is required.
use openpxe_iso_store::{BootKind, IsoMeta};
use std::fmt::Write as _;
/// Render the full `grub.cfg` for the signed-GRUB menu.
///
/// `base_url` is the public HTTP base (`http://10.0.0.5` or
/// `http://10.0.0.5:8080`) — converted to GRUB's `(http,host:port)`
/// device prefix for kernel/initrd fetches.
#[must_use]
pub fn render_grub_menu(isos: &[IsoMeta], base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let dev = grub_http_device(base);
let mut s = String::new();
let _ = writeln!(s, "# OpenPXE — Secure Boot menu (signed shim+GRUB chain)");
// v0.7.1: before showing the limited signed menu, try to hand the
// boot back to full iPXE *in this same boot cycle*. With Secure Boot
// OFF the chainload succeeds and the client gets the complete iPXE
// feature set (sanboot, wimboot, the full menu) despite having been
// escalated here. With Secure Boot ON, shim's verifier refuses the
// unsigned image INLINE — no reboot, no failed cycle — and execution
// falls through to the signed menu below. The all-drivers build is
// used because a MAC only lands here after the firmware-net build
// already failed once.
let _ = writeln!(s, "if [ \"$grub_cpu\" = \"arm64\" ]; then");
let _ = writeln!(s, " set openpxe_ipxe=ipxe-arm64.efi");
let _ = writeln!(s, "else");
let _ = writeln!(s, " set openpxe_ipxe=ipxe.efi");
let _ = writeln!(s, "fi");
let _ = writeln!(s, "if chainloader {dev}/ipxe/$openpxe_ipxe ; then");
let _ = writeln!(s, " boot");
let _ = writeln!(s, "fi");
let _ = writeln!(s);
let _ = writeln!(s, "set timeout=30");
let _ = writeln!(s, "set default=0");
let _ = writeln!(s);
let mut entries = 0usize;
for iso in isos {
for entry in &iso.boot_entries {
let BootKind::LinuxKernel {
kernel_url,
initrd_urls,
args,
} = &entry.kind
else {
continue;
};
// GRUB menu titles: keep quotes out of the label.
let title = entry.title.replace('"', "'");
let cmdline = args.cmdline.replace("${base-url}", base);
let _ = writeln!(s, "menuentry \"{} — {title}\" {{", iso.filename);
let _ = writeln!(s, " linux {dev}/{kernel_url} {cmdline}");
if !initrd_urls.is_empty() {
let _ = write!(s, " initrd");
for u in initrd_urls {
let _ = write!(s, " {dev}/{u}");
}
let _ = writeln!(s);
}
let _ = writeln!(s, "}}");
let _ = writeln!(s);
entries += 1;
}
}
if entries == 0 {
let _ = writeln!(
s,
"menuentry \"No Secure-Boot-bootable images on this server yet\" {{ true }}"
);
let _ = writeln!(s);
}
// Always give the operator a way off this screen.
let _ = writeln!(s, "menuentry \"Boot from local disk\" {{");
let _ = writeln!(s, " exit");
let _ = writeln!(s, "}}");
s
}
/// `http://10.0.0.5:8080` → `(http,10.0.0.5:8080)`. GRUB wants the
/// scheme as the device type and host[:port] as the device address.
fn grub_http_device(base: &str) -> String {
let host = base
.trim_start_matches("http://")
.trim_start_matches("https://");
format!("(http,{host})")
}
#[cfg(test)]
mod tests {
use super::*;
use openpxe_iso_store::{BootEntry, IsoSource, KernelArgs};
fn linux_iso() -> IsoMeta {
IsoMeta {
id: "alp".into(),
filename: "alpine.iso".into(),
size_bytes: 1,
sha256_hex: None,
uploaded_at: time::OffsetDateTime::UNIX_EPOCH,
source: IsoSource::Local,
introspection: openpxe_iso_store::IntrospectionReport::default(),
boot_entries: vec![BootEntry {
id: "alp-linux".into(),
title: "Linux installer".into(),
kind: BootKind::LinuxKernel {
kernel_url: "iso/alp/boot/vmlinuz".into(),
initrd_urls: vec!["iso/alp/boot/initrd".into()],
args: KernelArgs {
cmdline: "quiet repo=${base-url}/iso/alp.iso".into(),
},
},
}],
category: openpxe_iso_store::IsoCategory::default(),
password_hash: None,
}
}
#[test]
fn renders_linux_entries_with_http_device_urls() {
let cfg = render_grub_menu(&[linux_iso()], "http://10.0.0.5:8080/");
assert!(
cfg.contains("menuentry \"alpine.iso — Linux installer\""),
"{cfg}"
);
assert!(
cfg.contains("linux (http,10.0.0.5:8080)/iso/alp/boot/vmlinuz quiet repo=http://10.0.0.5:8080/iso/alp.iso"),
"{cfg}"
);
assert!(
cfg.contains("initrd (http,10.0.0.5:8080)/iso/alp/boot/initrd"),
"{cfg}"
);
assert!(cfg.contains("Boot from local disk"), "{cfg}");
}
#[test]
fn config_tries_ipxe_chainload_before_menu() {
// v0.7.1: SB-off machines recover full iPXE in the same boot;
// SB-on machines fail the chainload inline and reach the menu.
let cfg = render_grub_menu(&[linux_iso()], "http://10.0.0.5:8080");
let chain_pos = cfg
.find("if chainloader (http,10.0.0.5:8080)/ipxe/$openpxe_ipxe ; then")
.expect("chainload attempt missing");
let menu_pos = cfg.find("menuentry").expect("menu missing");
assert!(
chain_pos < menu_pos,
"chainload must precede the menu:\n{cfg}"
);
// Arch-conditional binary selection via GRUB's $grub_cpu.
assert!(cfg.contains("set openpxe_ipxe=ipxe-arm64.efi"), "{cfg}");
assert!(cfg.contains("set openpxe_ipxe=ipxe.efi"), "{cfg}");
}
#[test]
fn sanboot_and_wimboot_entries_are_omitted() {
let mut iso = linux_iso();
iso.boot_entries = vec![BootEntry {
id: "win".into(),
title: "Windows".into(),
kind: BootKind::SanBootIso {
iso_url: "iso/win.iso".into(),
},
}];
let cfg = render_grub_menu(&[iso], "http://10.0.0.5");
assert!(!cfg.contains("Windows"), "{cfg}");
assert!(cfg.contains("No Secure-Boot-bootable images"), "{cfg}");
}
}
+37 -12
View File
@@ -61,16 +61,24 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
// returns a full-screen 1024×768 PNG now — the operator's logo on a // returns a full-screen 1024×768 PNG now — the operator's logo on a
// dark field, or a default OpenPXE mark when none is uploaded. The // dark field, or a default OpenPXE mark when none is uploaded. The
// `--top 290` reserves the top band (where the logo paints) so the // `--top 290` reserves the top band (where the logo paints) so the
// menu text lands below it. On an iPXE build *with* `IMAGE_PNG` + // menu text lands below it.
// `CONSOLE_FRAMEBUFFER` (our x86_64 UEFI binaries, built from source //
// — see deploy/docker/Dockerfile) this paints the background and // v0.5.7: gate the whole command behind `iseq ${platform} efi`.
// overlays the menu. On a build *without* PNG support (the fetched // `console --picture` needs IMAGE_PNG + CONSOLE_FRAMEBUFFER, which
// BIOS/i386/arm64 binaries) the whole `console --picture …` command // only our from-source UEFI binaries carry (x86_64/arm64 UEFI — see
// fails and the `|| console` resets to a clean full-screen text // deploy/docker/Dockerfile). The fetched BIOS `undionly.kpxe` has
// menu. Either way there's no ASCII placeholder anymore. // neither, and on legacy BIOS the `--picture` attempt misbehaves
// *before* the trailing `|| console` fallback can recover (it tries
// to set a framebuffer mode the BIOS console can't honour). Guarding
// on platform means BIOS clients never issue the command at all —
// they drop straight to the plain text menu — while UEFI clients
// still get the graphical background. A PNG-less UEFI build (e.g. the
// upstream i386-efi baseline) still falls back gracefully through the
// same `|| console`. No operator toggle needed; mixed BIOS+UEFI
// fleets each get the right treatment automatically.
let _ = writeln!( let _ = writeln!(
s, s,
"console --picture {base}/branding/pxe-logo --top 290 || console" "iseq ${{platform}} efi && console --picture {base}/branding/pxe-logo --top 290 || console"
); );
// Map iPXE's ${{buildarch}} + ${{platform}} into the human form the // Map iPXE's ${{buildarch}} + ${{platform}} into the human form the
// user asked for (e.g. "x86 BIOS", "x86_64 UEFI", "arm64 UEFI"). // user asked for (e.g. "x86 BIOS", "x86_64 UEFI", "arm64 UEFI").
@@ -101,12 +109,13 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
} else { } else {
let _ = writeln!(s, "item --gap -- (no Linux ISOs uploaded)"); let _ = writeln!(s, "item --gap -- (no Linux ISOs uploaded)");
} }
if settings.windows_enabled && has_family(isos, is_windows_family) { // v0.5.8: Windows just works — no Settings toggle. Show the Windows
// installers submenu whenever a Windows ISO is present; entries boot
// via HTTP sanboot of the raw ISO, so no SMB/extraction is required.
if has_family(isos, is_windows_family) {
let _ = writeln!(s, "item windows Windows Installers >"); let _ = writeln!(s, "item windows Windows Installers >");
} else if settings.windows_enabled {
let _ = writeln!(s, "item --gap -- (no Windows ISOs uploaded)");
} else { } else {
let _ = writeln!(s, "item --gap -- (Windows support disabled in Settings)"); let _ = writeln!(s, "item --gap -- (no Windows ISOs uploaded)");
} }
let _ = writeln!( let _ = writeln!(
s, s,
@@ -693,6 +702,22 @@ mod password_tests {
assert!(s.contains("arm64 UEFI"), "{s}"); assert!(s.contains("arm64 UEFI"), "{s}");
} }
// v0.5.4: a full snapshot of the rendered top menu. The fragment
// `assert!`s above check specific invariants; this catches *any* other
// drift (a reordered item, a dropped line, changed spacing) so it's
// reviewed deliberately. The OpenPXE version is filtered out so the
// snapshot doesn't churn on every release bump.
#[test]
fn render_menu_snapshot() {
// Normalize the compile-time version so the snapshot doesn't churn
// on every release bump (no insta `filters` feature needed).
let rendered = render_menu(&[], &Settings::default(), "http://10.0.0.5").replace(
concat!("OpenPXE v", env!("CARGO_PKG_VERSION")),
"OpenPXE vX.Y.Z",
);
insta::assert_snapshot!(rendered);
}
#[test] #[test]
fn generated_scripts_do_not_emit_bare_or_trailing_fallbacks() { fn generated_scripts_do_not_emit_bare_or_trailing_fallbacks() {
let settings = Settings::default(); let settings = Settings::default();
+2
View File
@@ -15,6 +15,8 @@
pub mod app; pub mod app;
pub mod auth; pub mod auth;
pub mod error;
pub mod grub_script;
pub mod ipxe_script; pub mod ipxe_script;
pub mod iso_fs; pub mod iso_fs;
pub mod log_stream; pub mod log_stream;
+5 -1
View File
@@ -105,7 +105,11 @@ async fn send_email(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(),
.trim() .trim()
.parse() .parse()
.map_err(|e| format!("invalid To address '{}': {e}", cfg.smtp_to))?) .map_err(|e| format!("invalid To address '{}': {e}", cfg.smtp_to))?)
.subject(if subject.is_empty() { "OpenPXE" } else { subject }) .subject(if subject.is_empty() {
"OpenPXE"
} else {
subject
})
.body(body.to_string()) .body(body.to_string())
.map_err(|e| format!("could not build email: {e}"))?; .map_err(|e| format!("could not build email: {e}"))?;
+32
View File
@@ -336,3 +336,35 @@ fn redirect_with_session(location: &str, session: &str) -> Response {
IntoResponse::into_response, IntoResponse::into_response,
) )
} }
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
// v0.5.4: exercise the SAML metadata-URL fetch against a mock server —
// previously this path did a real network GET and had no coverage.
#[tokio::test]
async fn fetch_metadata_returns_body_on_200() {
let server = MockServer::start().await;
let xml = "<EntityDescriptor>idp</EntityDescriptor>";
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_string(xml))
.mount(&server)
.await;
let got = fetch_metadata(&server.uri()).await.expect("fetch ok");
assert_eq!(got, xml);
}
#[tokio::test]
async fn fetch_metadata_errors_on_non_2xx() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let err = fetch_metadata(&server.uri()).await.unwrap_err();
assert!(matches!(err, SamlError::Metadata(_)), "got {err:?}");
}
}
@@ -0,0 +1,36 @@
---
source: crates/http-api/src/ipxe_script.rs
expression: rendered
---
#!ipxe
# OpenPXE top-level menu - auto-generated, do not edit
set base-url http://10.0.0.5
set esc:hex 1b
set cls ${esc:string}[2J
iseq ${platform} efi && console --picture http://10.0.0.5/branding/pxe-logo --top 290 || console
set arch-label ${buildarch} ${platform}
iseq ${buildarch} i386 && iseq ${platform} pcbios && set arch-label x86 BIOS || iseq ${buildarch} x86_64 && iseq ${platform} efi && set arch-label x86_64 UEFI || iseq ${buildarch} arm64 && iseq ${platform} efi && set arch-label arm64 UEFI || true
:menu
menu OpenPXE - network boot menu
item --gap
item --gap -- ------------------------- Default -------------------------
item local Boot from Local HDD
item --gap -- ----------------------- Installers -----------------------
item --gap -- (no Linux ISOs uploaded)
item --gap -- (no Windows ISOs uploaded)
item --gap -- -------------------------- Tools --------------------------
item tools Tools >
item --gap -- ---------------------- Queued Deployment ---------------------
item queue Queued Deployment (join queue)
item --gap
item --key x exit Exit iPXE
item --gap
item --gap -- OpenPXE vX.Y.Z - ${arch-label}
choose --default queue --timeout 600000 target || goto menu
iseq ${target} local && chain http://10.0.0.5/boot/_local.ipxe || goto menu
iseq ${target} linux && chain http://10.0.0.5/boot/_linux_menu.ipxe || goto menu
iseq ${target} windows && chain http://10.0.0.5/boot/_windows_menu.ipxe || goto menu
iseq ${target} tools && chain http://10.0.0.5/boot/_tools_menu.ipxe || goto menu
iseq ${target} queue && chain http://10.0.0.5/boot/_queue.ipxe || goto menu
iseq ${target} exit && exit || goto menu
goto menu
+34 -3
View File
@@ -2,13 +2,19 @@ use crate::auth::SessionStore;
use crate::saml_routes::SamlRuntime; use crate::saml_routes::SamlRuntime;
use crate::uploads::UploadSessions; use crate::uploads::UploadSessions;
use openpxe_core::{ use openpxe_core::{
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus, AdminStore, BootLog, BootRulesStore, BootTokens, BrandingStore, ClientRegistry,
Metrics, NotifyStore, SettingsStore, SsoStore, DeploymentQueue, HostBindings, LogBus, Metrics, NotifyStore, SettingsStore, SsoStore,
};
use openpxe_iso_store::{
IsoStore, NfsShareManager, SftpShareManager, SmbManager, SmbShareManager, UnattendedStore,
}; };
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager, UnattendedStore};
use std::sync::Arc; use std::sync::Arc;
use time::OffsetDateTime; use time::OffsetDateTime;
/// Cached composited PXE boot-menu background: `(logo_rev, encoded PNG)`.
/// See `AppState::pxe_bg_cache`.
pub type PxeBgCache = Arc<parking_lot::Mutex<Option<(u64, bytes::Bytes)>>>;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub iso_store: IsoStore, pub iso_store: IsoStore,
@@ -23,10 +29,24 @@ pub struct AppState {
/// every `/boot/<entry>.ipxe` chain that goes on to serve a script /// every `/boot/<entry>.ipxe` chain that goes on to serve a script
/// (i.e. an image actually starting to install on a machine). /// (i.e. an image actually starting to install on a machine).
pub boot_log: BootLog, pub boot_log: BootLog,
/// v0.7.0: ordered label-based boot rules (MAC prefix / arch →
/// target) plus the optional boot-decision webhook. Consulted by the
/// top-level boot script after exact host bindings, before the menu.
pub boot_rules: BootRulesStore,
/// v0.7.0: short-lived access tokens for unattended answer files.
/// Minted into every generated answer-file URL; the serving endpoint
/// requires one (or an operator session) once an admin exists.
pub boot_tokens: BootTokens,
/// Operator-controlled UI overrides (custom logo). When the /// Operator-controlled UI overrides (custom logo). When the
/// operator hasn't uploaded anything, the WebUI serves the bundled /// operator hasn't uploaded anything, the WebUI serves the bundled
/// rainbow-horizon mark. /// rainbow-horizon mark.
pub branding: BrandingStore, pub branding: BrandingStore,
/// v0.6.2: cache of the composited PXE boot-menu background PNG,
/// keyed on the branding logo revision. Composing costs ~50-200 ms
/// of image decode/encode and **every** booting client fetches it
/// for `console --picture` — caching makes that one compose per
/// logo change instead of one per boot.
pub pxe_bg_cache: PxeBgCache,
/// Forms-auth admin record + first-run bootstrap state. When /// Forms-auth admin record + first-run bootstrap state. When
/// `admin.is_configured() == false`, the auth middleware passes /// `admin.is_configured() == false`, the auth middleware passes
/// every request through and `/api/me` reports `setup_required`. /// every request through and `/api/me` reports `setup_required`.
@@ -67,6 +87,13 @@ pub struct AppState {
/// In-process (no subprocess); supports HTTP Range requests on /// In-process (no subprocess); supports HTTP Range requests on
/// NFS-sourced ISOs because NFSv3 READ3 takes an explicit offset. /// NFS-sourced ISOs because NFSv3 READ3 takes an explicit offset.
pub nfs_shares: NfsShareManager, pub nfs_shares: NfsShareManager,
/// v0.5.5: SFTP-over-SSH share manager — pure-Rust userspace
/// consumer via `russh` + `russh-sftp` (ring backend, no OpenSSL).
/// Ships alongside SMB/NFS as the third remote-library protocol.
/// In-process (no subprocess, no kernel mount); supports HTTP Range
/// requests because SFTP opens a seekable file handle. Authenticates
/// the server's SSH host key on a trust-on-first-use basis.
pub sftp_shares: SftpShareManager,
/// v0.5.2: uploaded unattended-install answer files (Kickstart / /// v0.5.2: uploaded unattended-install answer files (Kickstart /
/// Preseed / Autoinstall / Windows answer files). Served on demand to /// Preseed / Autoinstall / Windows answer files). Served on demand to
/// booting clients with per-host hostname/IP/MAC templating; lives in /// booting clients with per-host hostname/IP/MAC templating; lives in
@@ -89,6 +116,10 @@ pub struct AppState {
/// `enp1s0`). Surfaced read-only on the Network tab. Empty if the /// `enp1s0`). Surfaced read-only on the Network tab. Empty if the
/// interface couldn't be identified. /// interface couldn't be identified.
pub nic_name: String, pub nic_name: String,
/// v0.7.2: physical link summary for that NIC (operstate, speed,
/// duplex, port MAC) — read from sysfs at startup; empty where
/// unavailable. Helps confirm which port answers PXE.
pub nic_link: String,
/// Subnet mask of the public interface in dotted-quad form. /// Subnet mask of the public interface in dotted-quad form.
pub subnet_mask: String, pub subnet_mask: String,
/// Default gateway IPv4 address. /// Default gateway IPv4 address.
+121 -10
View File
@@ -96,6 +96,8 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
"share" | "smb-share" => smb_share_command(state, tail).await, "share" | "smb-share" => smb_share_command(state, tail).await,
"smb" => smb_command(state, tail).await, "smb" => smb_command(state, tail).await,
"nfs" => nfs_share_command(state, tail).await, "nfs" => nfs_share_command(state, tail).await,
// v0.5.5: SFTP-over-SSH remote shares (in-process russh client).
"sftp" => sftp_share_command(state, tail).await,
"log" => log_command(state, tail), "log" => log_command(state, tail),
"whoami" => Ok("operator".to_string()), "whoami" => Ok("operator".to_string()),
"echo" => Ok(tail.join(" ")), "echo" => Ok(tail.join(" ")),
@@ -118,17 +120,21 @@ fn status_text(s: &AppState) -> String {
// v0.4.67: NFSv3 sources too. // v0.4.67: NFSv3 sources too.
let nfs_shares = s.nfs_shares.list(); let nfs_shares = s.nfs_shares.list();
let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count(); let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count();
// v0.5.5: SFTP-over-SSH sources too.
let sftp_shares = s.sftp_shares.list();
let sftp_reachable = sftp_shares.iter().filter(|m| m.reachable).count();
format!( format!(
"OpenPXE {ver}\n\ "OpenPXE {ver}\n\
base url: {base}\n\ base url: {base}\n\
interface: {nic}\n\ interface: {nic}\n\
uptime: {up}\n\ uptime: {up}\n\
isos: {n_isos} (local: {n_local}, smb: {n_smb}, nfs: {n_nfs})\n\ isos: {n_isos} (local: {n_local}, smb: {n_smb}, nfs: {n_nfs}, sftp: {n_sftp})\n\
clients: {n_clients}\n\ clients: {n_clients}\n\
queue: {n_entries}\n\ queue: {n_entries}\n\
smb server: {smb}\n\ smb server: {smb}\n\
smb shares: {n_smb_total} configured ({n_smb_active} reachable)\n\ smb shares: {n_smb_total} configured ({n_smb_active} reachable)\n\
nfs shares: {n_nfs_total} configured ({n_nfs_active} reachable)\n", nfs shares: {n_nfs_total} configured ({n_nfs_active} reachable)\n\
sftp shares: {n_sftp_total} configured ({n_sftp_active} reachable)\n",
ver = env!("CARGO_PKG_VERSION"), ver = env!("CARGO_PKG_VERSION"),
base = s.public_base_url, base = s.public_base_url,
nic = if s.nic_name.is_empty() { nic = if s.nic_name.is_empty() {
@@ -150,6 +156,10 @@ fn status_text(s: &AppState) -> String {
.iter() .iter()
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Nfs { .. })) .filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Nfs { .. }))
.count(), .count(),
n_sftp = isos
.iter()
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Sftp { .. }))
.count(),
n_clients = clients.len(), n_clients = clients.len(),
n_entries = queue_entries.len(), n_entries = queue_entries.len(),
smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")), smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")),
@@ -157,6 +167,8 @@ fn status_text(s: &AppState) -> String {
n_smb_active = smb_reachable, n_smb_active = smb_reachable,
n_nfs_total = nfs_shares.len(), n_nfs_total = nfs_shares.len(),
n_nfs_active = nfs_reachable, n_nfs_active = nfs_reachable,
n_sftp_total = sftp_shares.len(),
n_sftp_active = sftp_reachable,
) )
} }
@@ -177,6 +189,8 @@ fn isos_text(s: &AppState) -> String {
openpxe_iso_store::IsoSource::Smb { share_id, .. } => format!("smb:{share_id}"), openpxe_iso_store::IsoSource::Smb { share_id, .. } => format!("smb:{share_id}"),
// v0.4.67: NFSv3 via in-process nfs3_client. // v0.4.67: NFSv3 via in-process nfs3_client.
openpxe_iso_store::IsoSource::Nfs { share_id, .. } => format!("nfs:{share_id}"), openpxe_iso_store::IsoSource::Nfs { share_id, .. } => format!("nfs:{share_id}"),
// v0.5.5: SFTP-over-SSH via in-process russh.
openpxe_iso_store::IsoSource::Sftp { share_id, .. } => format!("sftp:{share_id}"),
}; };
let _ = writeln!( let _ = writeln!(
out, out,
@@ -321,9 +335,7 @@ async fn smb_share_command(s: &AppState, args: &[String]) -> Result<String, Stri
} }
Some("add") => { Some("add") => {
// share add //server/share [guest|user:password] // share add //server/share [guest|user:password]
let target = args let target = args.get(1).ok_or_else(|| {
.get(1)
.ok_or_else(|| {
"usage: share add //server/share [guest|user:password]".to_string() "usage: share add //server/share [guest|user:password]".to_string()
})?; })?;
// Accept either `//server/share` (UNC-style) or // Accept either `//server/share` (UNC-style) or
@@ -401,11 +413,7 @@ async fn nfs_share_command(s: &AppState, args: &[String]) -> Result<String, Stri
return Ok("(no NFS shares configured)".into()); return Ok("(no NFS shares configured)".into());
} }
let mut out = String::new(); let mut out = String::new();
let _ = writeln!( let _ = writeln!(out, "{:<24} {:<7} {:<6} TARGET", "ID", "STATUS", "ISOS");
out,
"{:<24} {:<7} {:<6} TARGET",
"ID", "STATUS", "ISOS"
);
for m in shares { for m in shares {
let status = if m.reachable { "ok" } else { "down" }; let status = if m.reachable { "ok" } else { "down" };
let _ = writeln!( let _ = writeln!(
@@ -476,6 +484,104 @@ async fn nfs_share_command(s: &AppState, args: &[String]) -> Result<String, Stri
} }
} }
// ── sftp (v0.5.5) ────────────────────────────────────────────────────────
//
// Parallel to nfs_share_command. The terminal `add` only supports
// password auth — pasting a multiline PEM private key through the
// terminal is impractical, so key-based shares are added via the WebUI.
async fn sftp_share_command(s: &AppState, args: &[String]) -> Result<String, String> {
match args.first().map(String::as_str) {
None | Some("list") => {
let shares = s.sftp_shares.list();
if shares.is_empty() {
return Ok("(no SFTP shares configured)".into());
}
let mut out = String::new();
let _ = writeln!(out, "{:<24} {:<7} {:<6} TARGET", "ID", "STATUS", "ISOS");
for m in shares {
let status = if m.reachable { "ok" } else { "down" };
let _ = writeln!(
out,
"{:<24} {:<7} {:<6} {}@{}:{}",
truncate(&m.id, 24),
status,
m.iso_count,
m.username,
m.server,
m.export,
);
if let Some(e) = m.last_error {
let _ = writeln!(out, " error: {e}");
}
if let Some(h) = m.last_hint {
let _ = writeln!(out, " hint: {h}");
}
}
Ok(out)
}
Some("add") => {
// sftp add <user>@<server>:<export> <password> [port]
let target = args.get(1).ok_or_else(|| {
"usage: sftp add <user>@<server>:<export> <password> [port] \
(key auth: use the WebUI)"
.to_string()
})?;
let password = args
.get(2)
.ok_or_else(|| "a password is required (key auth: use the WebUI)".to_string())?;
let (user, rest) = target
.split_once('@')
.ok_or_else(|| "target must be 'user@server:/export'".to_string())?;
let (server, export) = rest
.split_once(':')
.ok_or_else(|| "target must be 'user@server:/export'".to_string())?;
let port = args.get(3).and_then(|s| s.parse::<u16>().ok());
let req = openpxe_iso_store::SftpAddRequest {
server: server.to_string(),
export: export.to_string(),
username: Some(user.to_string()),
port,
password: Some(password.clone()),
private_key: None,
passphrase: None,
};
match s.sftp_shares.add(req).await {
Ok(m) => Ok(format!("added {} ({} isos)", m.id, m.iso_count)),
Err(e) => {
let mut out = format!("add failed: {}", e.error);
if let Some(h) = e.hint {
out.push_str("\nhint: ");
out.push_str(&h);
}
Err(out)
}
}
}
Some("remove") => {
let id = args
.get(1)
.ok_or_else(|| "usage: sftp remove <id>".to_string())?;
match s.sftp_shares.remove(id).await {
Ok(()) => Ok(format!("removed {id}")),
Err(e) => Err(format!("remove failed: {e}")),
}
}
Some("scan") => {
let id = args
.get(1)
.ok_or_else(|| "usage: sftp scan <id>".to_string())?;
match s.sftp_shares.rescan(id).await {
Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
Err(e) => Err(format!("scan failed: {e}")),
}
}
Some(other) => Err(format!(
"unknown sftp subcommand: {other}\ntry: sftp [list|add|remove|scan]"
)),
}
}
// ── smb ──────────────────────────────────────────────────────────────── // ── smb ────────────────────────────────────────────────────────────────
#[allow(clippy::unused_async)] #[allow(clippy::unused_async)]
@@ -622,6 +728,11 @@ OpenPXE terminal — available commands:
nfs remove <id> forget an NFS share nfs remove <id> forget an NFS share
nfs scan <id> re-list an NFS share for new ISOs nfs scan <id> re-list an NFS share for new ISOs
sftp list list configured SFTP-over-SSH shares
sftp add <user>@<srv>:<export> <pass> [port] add an SFTP share (key auth: WebUI)
sftp remove <id> forget an SFTP share
sftp scan <id> re-list an SFTP share for new ISOs
smb status outbound Samba state (Windows install media) smb status outbound Samba state (Windows install media)
smb start | stop | reload control the outbound smbd smb start | stop | reload control the outbound smbd
+12 -8
View File
@@ -9,6 +9,7 @@
use bytes::Bytes; use bytes::Bytes;
use openpxe_core::{Error, Result}; use openpxe_core::{Error, Result};
use openpxe_iso_store::{IsoMeta, IsoStore, UploadHandle}; use openpxe_iso_store::{IsoMeta, IsoStore, UploadHandle};
use parking_lot::RwLock;
use serde::Serialize; use serde::Serialize;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
@@ -19,7 +20,11 @@ const DEFAULT_CHUNK_SIZE: u64 = 8 * 1024 * 1024;
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct UploadSessions { pub struct UploadSessions {
inner: Arc<Mutex<HashMap<String, Arc<Mutex<UploadSession>>>>>, // v0.5.4: the registry is a sync `parking_lot::RwLock` — it's only ever
// briefly read/inserted/removed to look up a session, never held across
// an `.await`. The per-session lock below stays a `tokio::sync::Mutex`
// because `write_chunk` / `finish` are awaited while it's held.
inner: Arc<RwLock<HashMap<String, Arc<Mutex<UploadSession>>>>>,
} }
struct UploadSession { struct UploadSession {
@@ -67,8 +72,7 @@ impl UploadSessions {
}; };
self.inner self.inner
.lock() .write()
.await
.insert(upload_id.clone(), Arc::new(Mutex::new(session))); .insert(upload_id.clone(), Arc::new(Mutex::new(session)));
Ok(UploadStarted { Ok(UploadStarted {
@@ -88,7 +92,7 @@ impl UploadSessions {
chunk: Bytes, chunk: Bytes,
complete: bool, complete: bool,
) -> Result<UploadAppend> { ) -> Result<UploadAppend> {
let Some(session_lock) = self.inner.lock().await.get(upload_id).cloned() else { let Some(session_lock) = self.inner.read().get(upload_id).cloned() else {
return Err(Error::Invalid(format!("no such upload '{upload_id}'"))); return Err(Error::Invalid(format!("no such upload '{upload_id}'")));
}; };
@@ -119,7 +123,7 @@ impl UploadSessions {
if let Err(e) = handle.write_chunk(&chunk).await { if let Err(e) = handle.write_chunk(&chunk).await {
let handle = session.handle.take(); let handle = session.handle.take();
drop(session); drop(session);
self.inner.lock().await.remove(upload_id); self.inner.write().remove(upload_id);
if let Some(handle) = handle { if let Some(handle) = handle {
let _ = handle.abort().await; let _ = handle.abort().await;
} }
@@ -156,11 +160,11 @@ impl UploadSessions {
let meta = match handle.finish(store).await { let meta = match handle.finish(store).await {
Ok(meta) => meta, Ok(meta) => meta,
Err(e) => { Err(e) => {
self.inner.lock().await.remove(upload_id); self.inner.write().remove(upload_id);
return Err(e); return Err(e);
} }
}; };
self.inner.lock().await.remove(upload_id); self.inner.write().remove(upload_id);
Ok(UploadAppend::Complete { Ok(UploadAppend::Complete {
offset: new_offset, offset: new_offset,
iso: Box::new(meta), iso: Box::new(meta),
@@ -168,7 +172,7 @@ impl UploadSessions {
} }
pub async fn abort(&self, upload_id: &str) -> Result<()> { pub async fn abort(&self, upload_id: &str) -> Result<()> {
let Some(session_lock) = self.inner.lock().await.remove(upload_id) else { let Some(session_lock) = self.inner.write().remove(upload_id) else {
return Err(Error::Invalid(format!("no such upload '{upload_id}'"))); return Err(Error::Invalid(format!("no such upload '{upload_id}'")));
}; };
let mut session = session_lock.lock().await; let mut session = session_lock.lock().await;
+241 -24
View File
@@ -14,7 +14,7 @@ use axum::body::Body;
use axum::http::{header, Request, StatusCode}; use axum::http::{header, Request, StatusCode};
use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore}; use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore};
use openpxe_http_api::{build_router, AppState}; use openpxe_http_api::{build_router, AppState};
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbShareManager}; use openpxe_iso_store::{IsoStore, NfsShareManager, SftpShareManager, SmbShareManager};
use tempfile::tempdir; use tempfile::tempdir;
use tower::ServiceExt; use tower::ServiceExt;
@@ -96,6 +96,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
let settings = SettingsStore::load_or_default(dir.path()); let settings = SettingsStore::load_or_default(dir.path());
let smb_shares = SmbShareManager::new(dir.path(), iso_store.clone()); let smb_shares = SmbShareManager::new(dir.path(), iso_store.clone());
let nfs_shares = NfsShareManager::new(dir.path(), iso_store.clone()); let nfs_shares = NfsShareManager::new(dir.path(), iso_store.clone());
let sftp_shares = SftpShareManager::new(dir.path(), iso_store.clone());
let unattended = openpxe_iso_store::UnattendedStore::new(dir.path().join("unattended")); let unattended = openpxe_iso_store::UnattendedStore::new(dir.path().join("unattended"));
unattended.ensure_dir().await.unwrap(); unattended.ensure_dir().await.unwrap();
let log_bus = LogBus::new(64); let log_bus = LogBus::new(64);
@@ -114,7 +115,10 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
settings, settings,
hosts, hosts,
boot_log, boot_log,
boot_rules: openpxe_core::BootRulesStore::load_or_default(dir.path()),
boot_tokens: openpxe_core::BootTokens::new(),
branding, branding,
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
admin, admin,
sessions, sessions,
sso, sso,
@@ -124,12 +128,14 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
smb: None, smb: None,
smb_shares, smb_shares,
nfs_shares, nfs_shares,
sftp_shares,
unattended, unattended,
uploads: openpxe_http_api::uploads::UploadSessions::default(), uploads: openpxe_http_api::uploads::UploadSessions::default(),
log_bus, log_bus,
started_at: time::OffsetDateTime::now_utc(), started_at: time::OffsetDateTime::now_utc(),
public_base_url: "http://127.0.0.1".into(), public_base_url: "http://127.0.0.1".into(),
nic_name: "lo".into(), nic_name: "lo".into(),
nic_link: String::new(),
subnet_mask: "255.0.0.0".into(), subnet_mask: "255.0.0.0".into(),
gateway: "127.0.0.1".into(), gateway: "127.0.0.1".into(),
}; };
@@ -691,7 +697,7 @@ async fn log_recent_returns_buffered_lines() {
} }
#[tokio::test] #[tokio::test]
async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() { async fn windows_iso_renders_clean_sanboot_script_with_no_trust_store_writes() {
// Synthesize an ISO with a Windows volume label + the sources/boot.wim // Synthesize an ISO with a Windows volume label + the sources/boot.wim
// sentinel so introspection labels it WindowsPe with has_boot_wim. // sentinel so introspection labels it WindowsPe with has_boot_wim.
let mut buf = vec![0u8; 32 * 2048]; let mut buf = vec![0u8; 32 * 2048];
@@ -761,38 +767,35 @@ async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
"introspection should detect sources/boot.wim sentinel" "introspection should detect sources/boot.wim sentinel"
); );
// The boot entry should be a wimboot kind with the canonical 5-file // v0.5.8: Windows boots via iPXE HTTP sanboot of the raw ISO — no SMB,
// chain documented in the LinusTechTips iPXE-Windows guide. // no extraction, no in-ISO file serving, no operator toggle. The boot
// entry is a `san_boot_iso` kind pointing at the raw image.
let entry = &meta["boot_entries"][0]; let entry = &meta["boot_entries"][0];
assert_eq!(entry["kind"]["kind"], "wimboot"); assert_eq!(entry["kind"]["kind"], "san_boot_iso");
let files = entry["kind"]["files"].as_array().unwrap(); let iso_url = entry["kind"]["iso_url"].as_str().unwrap();
let names: Vec<&str> = files.iter().map(|f| f[0].as_str().unwrap()).collect(); assert!(
assert!(names.contains(&"bootmgr")); std::path::Path::new(iso_url)
assert!(names.contains(&"bootmgr.efi")); .extension()
assert!(names.contains(&"bcd")); .is_some_and(|e| e.eq_ignore_ascii_case("iso")),
assert!(names.contains(&"boot.sdi")); "sanboot should target the raw ISO, got: {iso_url}"
assert!(names.contains(&"boot.wim")); );
// Render the entry script and verify: // Render the entry script and verify:
// 1. It uses wimboot // 1. It uses `sanboot` against the raw ISO over HTTP
// 2. All 5 files are referenced via `initrd --name` // 2. NO trust-store / driver / testsigning operations slip in
// 3. NO trust-store / driver / testsigning operations slip in
let entry_id = entry["id"].as_str().unwrap(); let entry_id = entry["id"].as_str().unwrap();
let url = format!("/boot/{entry_id}.ipxe"); let url = format!("/boot/{entry_id}.ipxe");
let (s, body) = get(&app, &url).await; let (s, body) = get(&app, &url).await;
assert_eq!(s, StatusCode::OK); assert_eq!(s, StatusCode::OK);
let script = String::from_utf8(body).unwrap(); let script = String::from_utf8(body).unwrap();
assert!(script.contains("kernel "), "missing kernel line:\n{script}");
assert!( assert!(
script.contains("ipxe/wimboot"), script.contains("sanboot"),
"missing wimboot loader:\n{script}" "missing sanboot line:\n{script}"
); );
for tag in ["bootmgr", "bootmgr.efi", "bcd", "boot.sdi", "boot.wim"] {
assert!( assert!(
script.contains(&format!("initrd --name {tag}")), script.contains(&format!("/{iso_url}")),
"missing `initrd --name {tag}` line:\n{script}" "sanboot should reference the raw ISO url:\n{script}"
); );
}
// Hard guarantees we never want to see in any client-facing script. // Hard guarantees we never want to see in any client-facing script.
let lower = script.to_lowercase(); let lower = script.to_lowercase();
for forbidden in [ for forbidden in [
@@ -1489,9 +1492,10 @@ async fn api_docs_lists_known_endpoints() {
} }
for needle in [ for needle in [
"/api/isos", "/api/isos",
"/api/isos/:id/category", // v0.6.3: docs use axum 0.8's `{param}` capture syntax.
"/api/isos/{id}/category",
"/api/storage/disk", "/api/storage/disk",
"/api/branding/logo/:slot", "/api/branding/logo/{slot}",
"/api/unattended", "/api/unattended",
"/api/boot-log", "/api/boot-log",
"/metrics", "/metrics",
@@ -1536,6 +1540,60 @@ async fn status_exposes_custom_logo_flag() {
); );
} }
/// v0.5.4 guard: the typed `StatusResponse` must keep every key the WebUI
/// (`crates/webui/src/app.js`) reads off `/api/status`. If a refactor drops
/// or renames one, the dashboard silently breaks — this catches it.
#[tokio::test]
async fn status_contract_has_all_ui_keys() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, body) = get(&app, "/api/status").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
for key in [
"version",
"public_base_url",
"iso_count",
"client_count",
"queue_count",
"imaging_count",
"waiting_count",
"ipxe_assets",
"settings",
"smb_share_count",
"smb_share_reachable",
"nfs_share_count",
"nfs_share_reachable",
"host_bindings",
"custom_logo",
"branding",
"unattended_count",
"uptime_secs",
"started_at",
"nic_name",
"subnet_mask",
"gateway",
] {
assert!(
v.get(key).is_some(),
"/api/status missing UI key '{key}': {v}"
);
}
// Nested branding presence the Settings tab reads.
for key in ["light", "dark", "client", "rev"] {
assert!(
v["branding"].get(key).is_some(),
"/api/status branding missing '{key}': {v}"
);
}
// started_at must remain an RFC3339 string (the UI does fmtUptime on
// uptime_secs but renders started_at as text), not a serialized struct.
assert!(
v["started_at"].is_string(),
"started_at should serialize as a string: {v}"
);
}
async fn put_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec<u8>) { async fn put_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec<u8>) {
let res = router let res = router
.clone() .clone()
@@ -2555,3 +2613,162 @@ async fn acs_garbage_is_rejected_without_500() {
assert!(location(&resp).contains("sso_error")); assert!(location(&resp).contains("sso_error"));
assert!(!has_session_cookie(&resp)); assert!(!has_session_cookie(&resp));
} }
// ─── v0.7.0: tokenized answer files + boot rules ────────────────────────────
#[tokio::test]
async fn unattended_requires_token_once_admin_exists() {
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
// Upload an answer file while in setup mode (everything open).
let (ct, body) =
multipart_iso_body("ks.ks", b"install\nrootpw s3cret\n%packages\n@core\n%end\n");
let (s, b) = post_multipart(&app, "/api/unattended", &ct, body).await;
assert_eq!(s, StatusCode::CREATED);
let id = serde_json::from_slice::<serde_json::Value>(&b).unwrap()["id"]
.as_str()
.unwrap()
.to_string();
// Pre-setup, the file serves openly (bootstrap parity with the
// auth middleware).
let (s, _) = get(&app, &format!("/unattended/{id}")).await;
assert_eq!(s, StatusCode::OK);
// Create the admin → the gate arms.
let (s, _, cookies) = post_collect(
&app,
"/api/setup",
r#"{"username":"admin","password":"hunter2hunter2"}"#,
)
.await;
assert_eq!(s, StatusCode::CREATED);
let session = session_value(&cookies).unwrap();
// Bare fetch (the CVE-2026-0386 harvesting pattern) is refused.
let (s, _) = get(&app, &format!("/unattended/{id}")).await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
// Garbage token is refused.
let (s, _) = get(&app, &format!("/unattended/{id}?t=bogus")).await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
// A token minted for a *different* file is refused.
let other = state.boot_tokens.mint("some-other-file");
let (s, _) = get(&app, &format!("/unattended/{id}?t={other}")).await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
// The boot-scoped token OpenPXE mints into generated URLs passes.
let tok = state.boot_tokens.mint(&id);
let (s, b) = get(&app, &format!("/unattended/{id}?t={tok}")).await;
assert_eq!(s, StatusCode::OK);
assert!(String::from_utf8_lossy(&b).contains("rootpw"));
// A logged-in operator (browser testing) passes too.
let (s, _) = get_with_cookie(&app, &format!("/unattended/{id}"), &session).await;
assert_eq!(s, StatusCode::OK);
}
#[tokio::test]
async fn boot_script_for_pinned_unattended_carries_live_token() {
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await;
assert_eq!(s, StatusCode::CREATED);
let (ct, body) = multipart_iso_body("ks.ks", b"install\n%packages\n@core\n%end\n");
let (s, b) = post_multipart(&app, "/api/unattended", &ct, body).await;
assert_eq!(s, StatusCode::CREATED);
let ks_id = serde_json::from_slice::<serde_json::Value>(&b).unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let mac = "aa:bb:cc:dd:ee:71";
let pin =
format!(r#"{{"mac":"{mac}","target":"fake-alpine-linux","unattended_file":"{ks_id}"}}"#);
let (s, _) = post_json(&app, "/api/hosts", &pin).await;
assert_eq!(s, StatusCode::CREATED);
let (s, b) = get(&app, &format!("/boot/fake-alpine-linux.ipxe?mac={mac}")).await;
assert_eq!(s, StatusCode::OK);
let script = String::from_utf8_lossy(&b).into_owned();
// The injected inst.ks URL ends with a token that is live for the file.
let tok = script
.split("t=")
.nth(1)
.and_then(|rest| rest.split_whitespace().next())
.expect("kernel arg should carry t=<token>");
assert!(
state.boot_tokens.check(tok, &ks_id),
"token in boot script must be live:\n{script}"
);
}
#[tokio::test]
async fn boot_rules_match_and_persist_via_api() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await;
assert_eq!(s, StatusCode::CREATED);
// Save a rule: any MAC under aa:bb:cc, any arch → the Linux entry.
let cfg = r#"{"rules":[{"mac_prefix":"AA-BB-CC","arch":"","target":"fake-alpine-linux","enabled":true,"note":"rack"}],"webhook_url":""}"#;
let (s, _) = put_json(&app, "/api/boot-rules", cfg).await;
assert_eq!(s, StatusCode::NO_CONTENT);
// The config reads back (prefix normalized to colons).
let (s, b) = get(&app, "/api/boot-rules").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["rules"][0]["mac_prefix"], "aa:bb:cc");
// A matching client short-circuits to the target...
let (s, b) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:09&arch=uefi-x64").await;
assert_eq!(s, StatusCode::OK);
let script = String::from_utf8_lossy(&b);
assert!(
script.contains("boot rule -> fake-alpine-linux"),
"rule did not chain:\n{script}"
);
// ...while a non-matching one still gets the menu.
let (s, b) = get(&app, "/boot.ipxe?mac=11:22:33:00:00:09&arch=uefi-x64").await;
assert_eq!(s, StatusCode::OK);
assert!(
String::from_utf8_lossy(&b).contains("menu"),
"non-matching client should see the menu"
);
}
#[tokio::test]
async fn arch_selective_rule_ignores_other_arches() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await;
assert_eq!(s, StatusCode::CREATED);
let cfg = r#"{"rules":[{"mac_prefix":"","arch":"uefi-arm64","target":"fake-alpine-linux","enabled":true,"note":""}],"webhook_url":""}"#;
let (s, _) = put_json(&app, "/api/boot-rules", cfg).await;
assert_eq!(s, StatusCode::NO_CONTENT);
// x64 client: no match → menu.
let (s, b) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:01&arch=uefi-x64").await;
assert_eq!(s, StatusCode::OK);
assert!(!String::from_utf8_lossy(&b).contains("boot rule ->"));
// arm64 client: match.
let (s, b) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:01&arch=uefi-arm64").await;
assert_eq!(s, StatusCode::OK);
assert!(String::from_utf8_lossy(&b).contains("boot rule -> fake-alpine-linux"));
}
#[tokio::test]
async fn boot_rule_driver_mode_pin_round_trips_via_api() {
// v0.7.1: a rule may pin only a boot binary (no target) — the API
// must persist and return it for the DHCP proxy to consult.
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
let cfg = r#"{"rules":[{"mac_prefix":"aa:bb:cc","arch":"","target":"","driver_mode":"shim","enabled":true,"note":"SB rack"}],"webhook_url":""}"#;
let (s, _) = put_json(&app, "/api/boot-rules", cfg).await;
assert_eq!(s, StatusCode::NO_CONTENT);
let (s, b) = get(&app, "/api/boot-rules").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["rules"][0]["driver_mode"], "shim");
// And the store the DHCP proxy shares resolves the pin.
assert_eq!(
state.boot_rules.driver_mode_hint("aa:bb:cc:00:00:07", None),
Some(openpxe_core::DriverMode::Shim)
);
}
+53 -33
View File
@@ -6,43 +6,40 @@
//! missing, that architecture simply won't have PXE support — we log at //! missing, that architecture simply won't have PXE support — we log at
//! startup and serve what we have. //! startup and serve what we have.
//! //!
//! Filename convention (matches `ClientArch::ipxe_bootfile`): //! Filename convention (matches `ClientArch::ipxe_bootfile_mode`):
//!
//! DriverMode::Firmware (default — reuse the firmware UNDI/SNP NIC stack):
//! - `undionly.kpxe` — Legacy x86 BIOS //! - `undionly.kpxe` — Legacy x86 BIOS
//! - `snponly-i386.efi` — IA32 UEFI //! - `snponly-i386.efi` — IA32 UEFI
//! - `snponly.efi` — x86_64 UEFI //! - `snponly.efi` — x86_64 UEFI
//! - `snponly-arm32.efi` — ARM32 UEFI
//! - `snponly-arm64.efi` — ARM64 UEFI //! - `snponly-arm64.efi` — ARM64 UEFI
//! - `ipxe.efi` (fallback) — UEFI with bundled drivers, if snponly fails on a NIC //!
//! DriverMode::Builtin (v0.6.1 automatic fallback — iPXE's own NIC drivers,
//! advertised when a firmware-net boot fails to chainload):
//! - `ipxe.pxe` — Legacy x86 BIOS
//! - `ipxe-i386.efi` — IA32 UEFI
//! - `ipxe.efi` — x86_64 UEFI (built from source with PNG)
//! - `ipxe-arm64.efi` — ARM64 UEFI
//!
//! - `wimboot` — Windows boot shim (fetched separately for WIM chains) //! - `wimboot` — Windows boot shim (fetched separately for WIM chains)
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
use openpxe_core::ClientArch; use openpxe_core::{ClientArch, DriverMode};
use rust_embed::Embed; use rust_embed::Embed;
#[derive(Embed)] #[derive(Embed)]
#[folder = "../../assets/ipxe/"] #[folder = "../../assets/ipxe/"]
#[include = "*.kpxe"] #[include = "*.kpxe"]
#[include = "*.efi"] #[include = "*.efi"]
#[include = "*.pxe"]
#[include = "wimboot"] #[include = "wimboot"]
pub struct IpxeAssets; pub struct IpxeAssets;
/// Return the embedded iPXE binary for `arch`, or `None` if we didn't bundle /// Return a named embedded asset (e.g. `snponly.efi`, `wimboot`) as a
/// one for that architecture. /// `Cow` over the embedded bytes. In release builds the data is borrowed
#[must_use] /// straight from the binary's rodata — **zero copy** — which matters
pub fn bootfile_bytes(arch: ClientArch) -> Option<Vec<u8>> { /// because the TFTP and HTTP serving paths hit this for every boot
let name = arch.ipxe_bootfile()?; /// (`ipxe.efi` is ~1 MiB). Debug builds read from disk and return Owned.
IpxeAssets::get(name).map(|f| f.data.into_owned())
}
/// Return a named asset directly (e.g. `wimboot`, or a fallback `ipxe.efi`).
#[must_use]
pub fn asset_bytes(name: &str) -> Option<Vec<u8>> {
IpxeAssets::get(name).map(|f| f.data.into_owned())
}
/// Same as [`asset_bytes`] but returns the embedded slice directly,
/// avoiding the heap copy when the caller only needs to read the
/// payload. Falls back to None for unknown names.
#[must_use] #[must_use]
pub fn asset_slice(name: &str) -> Option<std::borrow::Cow<'static, [u8]>> { pub fn asset_slice(name: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
IpxeAssets::get(name).map(|f| f.data) IpxeAssets::get(name).map(|f| f.data)
@@ -56,25 +53,48 @@ pub fn list_assets() -> Vec<String> {
.collect() .collect()
} }
/// Log at startup which iPXE binaries are present and which are missing. /// Log at startup which iPXE binaries are present and which are missing, for
/// both driver modes. The Firmware-mode binaries are required for PXE on each
/// arch; the Builtin-mode binaries are the optional automatic NIC-driver
/// fallback (v0.6.1) — without one, escalation simply can't help that arch.
pub fn log_availability() { pub fn log_availability() {
let have: std::collections::HashSet<String> = list_assets().into_iter().collect(); let have: std::collections::HashSet<String> = list_assets().into_iter().collect();
let needed = [ let arches = [
(ClientArch::LegacyX86, "undionly.kpxe"), ClientArch::LegacyX86,
(ClientArch::Ia32Uefi, "snponly-i386.efi"), ClientArch::Ia32Uefi,
(ClientArch::X64Uefi, "snponly.efi"), ClientArch::X64Uefi,
// ARM32 UEFI deferred — no upstream snponly binary published. // ARM32 UEFI deferred — no upstream binary published in either mode.
(ClientArch::Arm64Uefi, "snponly-arm64.efi"), ClientArch::Arm64Uefi,
]; ];
for (arch, name) in needed { for arch in arches {
for mode in [DriverMode::Firmware, DriverMode::Builtin, DriverMode::Shim] {
let Some(name) = arch.ipxe_bootfile_mode(mode) else {
continue;
};
if have.contains(name) { if have.contains(name) {
tracing::info!(target: "openpxe::ipxe", "bundled iPXE for {}: {}", arch.as_str(), name); tracing::info!(
} else { target: "openpxe::ipxe",
"bundled iPXE for {} [{mode:?}]: {name}", arch.as_str()
);
} else if mode == DriverMode::Firmware {
tracing::warn!( tracing::warn!(
target: "openpxe::ipxe", target: "openpxe::ipxe",
"MISSING iPXE binary for {}: {} — clients of this arch will not PXE boot", "MISSING iPXE binary for {} [{mode:?}]: {name} — clients of this arch will not PXE boot",
arch.as_str(), name arch.as_str()
);
} else if mode == DriverMode::Builtin {
tracing::info!(
target: "openpxe::ipxe",
"no built-in-driver fallback for {} [{mode:?}]: {name} — auto NIC driver escalation unavailable for this arch",
arch.as_str()
);
} else {
tracing::info!(
target: "openpxe::ipxe",
"no signed shim chain for {} [{mode:?}]: {name} — Secure Boot clients of this arch can't be served",
arch.as_str()
); );
} }
} }
} }
}
+4
View File
@@ -39,6 +39,10 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg",
# kernel mount. See crates/iso-store/src/nfs_share.rs for usage. # kernel mount. See crates/iso-store/src/nfs_share.rs for usage.
nfs3_client = { workspace = true } nfs3_client = { workspace = true }
nfs3_types = { workspace = true } nfs3_types = { workspace = true }
# v0.5.5: pure-Rust SSH/SFTP client (ring backend) for the SFTP remote
# share path. See crates/iso-store/src/sftp_share.rs for usage.
russh = { workspace = true }
russh-sftp = { workspace = true }
# Needed for the Stream trait that wraps the mpsc receiver feeding # Needed for the Stream trait that wraps the mpsc receiver feeding
# NFS read-loop bytes into axum's Body::from_stream. # NFS read-loop bytes into axum's Body::from_stream.
futures = { workspace = true } futures = { workspace = true }
+5 -3
View File
@@ -25,9 +25,11 @@ pub enum BootKind {
wimboot_url: String, wimboot_url: String,
files: Vec<(String, String)>, files: Vec<(String, String)>,
}, },
/// Last-resort: SAN-boot the ISO as an emulated CD. Only works for small /// SAN-boot the raw ISO as an emulated CD (iPXE `sanboot`). The emulated
/// ISOs (<~1 GiB) and older distros. Kept for completeness, not the /// CD is backed by on-demand HTTP range reads, so ISO size is *not* a
/// default. /// constraint — this is the primary path for Windows (v0.5.8) and for any
/// El Torito-bootable image we don't special-case: ESXi/VMvisor
/// installers, BSDs, firmware/diagnostic tools, custom spins (v0.6.0).
SanBootIso { iso_url: String }, SanBootIso { iso_url: String },
} }
+214 -10
View File
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
use std::io::{Read, Seek, SeekFrom}; use std::io::{Read, Seek, SeekFrom};
use std::path::Path; use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum DistroFamily { pub enum DistroFamily {
DebianUbuntu, DebianUbuntu,
@@ -22,10 +22,22 @@ pub enum DistroFamily {
Arch, Arch,
Alpine, Alpine,
WindowsPe, WindowsPe,
#[default]
Unknown, Unknown,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] /// Bumped whenever the introspection logic changes in a way that should
/// re-classify already-uploaded ISOs. On startup the store re-runs
/// `introspect` on any *local* ISO whose persisted report predates this
/// revision (see `IsoStore::load_from_disk`), so an upgrade fixes stale
/// metadata — e.g. a Windows 11 ISO tagged `Unknown` by an older binary —
/// without the operator having to delete and re-upload it.
///
/// rev 1 (v0.5.9): added El Torito boot-catalog detection + broadened
/// Windows (UDF/UTF-16) detection becomes retroactive.
pub const INTROSPECT_REV: u32 = 1;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IntrospectionReport { pub struct IntrospectionReport {
pub family: DistroFamily, pub family: DistroFamily,
pub volume_label: Option<String>, pub volume_label: Option<String>,
@@ -35,17 +47,28 @@ pub struct IntrospectionReport {
pub initrd_paths: Vec<String>, pub initrd_paths: Vec<String>,
/// True if `sources/boot.wim` present — Windows install media. /// True if `sources/boot.wim` present — Windows install media.
pub has_boot_wim: bool, pub has_boot_wim: bool,
/// True if the ISO carries an El Torito boot catalog — i.e. it is
/// bootable by BIOS/UEFI firmware and therefore by iPXE `sanboot`
/// (emulated CD). This is the authoritative "can this boot at all?"
/// signal for ISOs we can't classify as Linux or Windows (BSDs, ESXi,
/// firmware tools, custom spins). A *data* ISO (e.g. a VMware vCenter
/// appliance bundle) has no boot catalog and reports `false`. v0.5.9.
#[serde(default)]
pub el_torito: bool,
/// Revision of the introspection logic that produced this report. Old
/// `meta.json` files without the field deserialize as 0, which is
/// below [`INTROSPECT_REV`], triggering a one-time re-introspect on
/// the next startup. v0.5.9.
#[serde(default)]
pub introspect_rev: u32,
} }
/// Probe an ISO file on disk. Never fails — on unrecoverable IO error we log /// Probe an ISO file on disk. Never fails — on unrecoverable IO error we log
/// and return an `Unknown` family so the uploader still sees a record. /// and return an `Unknown` family so the uploader still sees a record.
pub fn introspect(path: &Path) -> IntrospectionReport { pub fn introspect(path: &Path) -> IntrospectionReport {
let mut report = IntrospectionReport { let mut report = IntrospectionReport {
family: DistroFamily::Unknown, introspect_rev: INTROSPECT_REV,
volume_label: None, ..Default::default()
kernel_path: None,
initrd_paths: Vec::new(),
has_boot_wim: false,
}; };
let Ok(mut f) = std::fs::File::open(path) else { let Ok(mut f) = std::fs::File::open(path) else {
@@ -68,6 +91,11 @@ pub fn introspect(path: &Path) -> IntrospectionReport {
} }
} }
// Does the ISO have an El Torito boot catalog? This is what decides
// whether an ISO we *can't* otherwise classify is bootable at all —
// a bootable ISO sanboots; a data/appliance ISO (no catalog) can't.
report.el_torito = detect_el_torito(&mut f);
// Cheap content scan: read the first ~64 MiB, look for signature filenames. // Cheap content scan: read the first ~64 MiB, look for signature filenames.
// This is enough to identify `sources/boot.wim` (Windows) and common // This is enough to identify `sources/boot.wim` (Windows) and common
// kernel/initrd paths for the major Linux distros. // kernel/initrd paths for the major Linux distros.
@@ -75,7 +103,13 @@ pub fn introspect(path: &Path) -> IntrospectionReport {
let scan_bytes = 64 * 1024 * 1024; let scan_bytes = 64 * 1024 * 1024;
let mut buf = vec![0u8; 1024 * 1024]; let mut buf = vec![0u8; 1024 * 1024];
let mut read_total = 0usize; let mut read_total = 0usize;
let mut haystack = Vec::with_capacity(scan_bytes.min(32 * 1024 * 1024)); // Size the haystack to what will actually be read — the scan cap or
// the file itself, whichever is smaller — so the fill never reallocs
// and a small ISO doesn't reserve the full 64 MiB.
let file_len = f.metadata().map_or(usize::MAX, |m| {
usize::try_from(m.len()).unwrap_or(usize::MAX)
});
let mut haystack = Vec::with_capacity(scan_bytes.min(file_len));
while read_total < scan_bytes { while read_total < scan_bytes {
let n = f.read(&mut buf).unwrap_or(0); let n = f.read(&mut buf).unwrap_or(0);
if n == 0 { if n == 0 {
@@ -85,12 +119,37 @@ pub fn introspect(path: &Path) -> IntrospectionReport {
read_total += n; read_total += n;
} }
// `sources/boot.wim` is the definitive Windows-install-media marker
// when the ISO exposes ASCII (ISO9660/Joliet) names. `contains_ascii`
// is case-insensitive, so one form covers BOOT.WIM / boot.wim and the
// backslash variant.
if contains_ascii(&haystack, b"sources/boot.wim") if contains_ascii(&haystack, b"sources/boot.wim")
|| contains_ascii(&haystack, b"SOURCES/BOOT.WIM") || contains_ascii(&haystack, b"sources\\boot.wim")
|| contains_ascii(&haystack, b"SOURCES\\BOOT.WIM")
{ {
report.has_boot_wim = true; report.has_boot_wim = true;
report.family = DistroFamily::WindowsPe;
}
// v0.5.8: broaden Windows detection. Modern Windows 10/11 ISOs are
// UDF — filenames are stored as UTF-16 (so the ASCII scan above misses
// them) and the volume label is a cryptic Microsoft string (so
// `family_from_label` misses it too). Booting is via HTTP sanboot of
// the raw ISO (no boot.wim extraction), so we only need the *family*.
// Catch the common cases: well-known Windows markers in either ASCII
// or UTF-16LE within the first 16 MiB, plus a filename hint.
if report.family == DistroFamily::Unknown { if report.family == DistroFamily::Unknown {
let head = &haystack[..haystack.len().min(16 * 1024 * 1024)];
let ascii_markers: [&[u8]; 4] = [
b"bootmgr",
b"sources/install.wim",
b"sources/install.esd",
b"efi/microsoft",
];
let utf16_markers = ["bootmgr", "boot.wim", "install.wim", "microsoft"];
let looks_windows = ascii_markers.iter().any(|m| contains_ascii(head, m))
|| utf16_markers.iter().any(|m| contains_utf16le_ci(head, m))
|| filename_looks_windows(path);
if looks_windows {
report.family = DistroFamily::WindowsPe; report.family = DistroFamily::WindowsPe;
} }
} }
@@ -158,6 +217,83 @@ fn contains_ascii(haystack: &[u8], needle: &[u8]) -> bool {
.any(|w| w.eq_ignore_ascii_case(needle)) .any(|w| w.eq_ignore_ascii_case(needle))
} }
/// Case-insensitive search for an ASCII string encoded as UTF-16LE — the
/// way UDF (and thus modern Windows ISOs) store filenames. Each character
/// is two bytes: the ASCII low byte (compared case-insensitively) followed
/// by a 0 high byte. v0.5.8.
fn contains_utf16le_ci(haystack: &[u8], ascii: &str) -> bool {
let n = ascii.len();
if n == 0 || haystack.len() < n * 2 {
return false;
}
let lower: Vec<u8> = ascii.bytes().map(|b| b.to_ascii_lowercase()).collect();
haystack.windows(n * 2).any(|w| {
lower
.iter()
.enumerate()
.all(|(i, &c)| w[i * 2 + 1] == 0 && w[i * 2].to_ascii_lowercase() == c)
})
}
/// Filename heuristic: a stock Windows ISO almost always carries an obvious
/// token in its name (e.g. `..._windows_11_...`, `Win10`, `winserver`).
/// Used only as a last-resort family hint when the content scan and volume
/// label are inconclusive. v0.5.8.
fn filename_looks_windows(path: &Path) -> bool {
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_ascii_lowercase();
const TOKENS: [&str; 6] = [
"windows",
"winpe",
"win10",
"win11",
"winserver",
"win-server",
];
TOKENS.iter().any(|t| name.contains(t))
}
/// The boot-system identifier string in an El Torito Boot Record Volume
/// Descriptor (offset 7, NUL-padded to 32 bytes).
const EL_TORITO_ID: &[u8] = b"EL TORITO SPECIFICATION";
/// Detect an El Torito boot catalog — the marker that an ISO is bootable
/// by BIOS/UEFI firmware (and thus by iPXE `sanboot`).
///
/// The ISO9660 Volume Descriptor Set starts at LBA 16 (offset 0x8000) and
/// runs one 2048-byte descriptor per sector until a Set Terminator
/// (type 0xFF). A Boot Record descriptor (type 0x00) whose 32-byte boot
/// system identifier reads "EL TORITO SPECIFICATION" means the image
/// declares an El Torito boot catalog. We only confirm its presence — we
/// don't parse the catalog (sanboot/the firmware does that). The walk is
/// capped so a malformed/huge image can't spin us. v0.5.9.
fn detect_el_torito(f: &mut std::fs::File) -> bool {
let mut vd = [0u8; 2048];
for lba in 16u64..32 {
if f.seek(SeekFrom::Start(lba * 2048)).is_err() || f.read_exact(&mut vd).is_err() {
return false;
}
// Every descriptor in the set carries the "CD001" magic; once it's
// missing we've walked off the end of a valid set.
if &vd[1..6] != b"CD001" {
return false;
}
match vd[0] {
// Boot Record descriptor carrying the El Torito signature.
0x00 if vd[7..7 + EL_TORITO_ID.len()] == *EL_TORITO_ID => return true,
// Volume Descriptor Set Terminator — nothing bootable found.
0xFF => return false,
// Any other descriptor (incl. a non-El-Torito boot record) —
// keep walking the set.
_ => {}
}
}
false
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -179,4 +315,72 @@ mod tests {
assert_eq!(family_from_label("ARCH_202604"), DistroFamily::Arch); assert_eq!(family_from_label("ARCH_202604"), DistroFamily::Arch);
assert_eq!(family_from_label("weird-custom"), DistroFamily::Unknown); assert_eq!(family_from_label("weird-custom"), DistroFamily::Unknown);
} }
#[test]
fn utf16le_marker_matches_case_insensitively() {
// "boot.wim" encoded UTF-16LE, mixed case — UDF stores Windows
// filenames this way, which the ASCII scan can't see.
let s = "BOOT.WIM";
let utf16: Vec<u8> = s.bytes().flat_map(|b| [b, 0]).collect();
let mut hay = vec![0u8; 8];
hay.extend_from_slice(&utf16);
hay.extend_from_slice(&[1, 2, 3]);
assert!(contains_utf16le_ci(&hay, "boot.wim"));
assert!(contains_utf16le_ci(&hay, "Boot.Wim"));
assert!(!contains_utf16le_ci(&hay, "install.wim"));
// An ASCII (not UTF-16) occurrence must NOT match the UTF-16 scan.
assert!(!contains_utf16le_ci(b"boot.wim plain ascii", "boot.wim"));
}
#[test]
fn el_torito_boot_catalog_detected() {
let dir = tempfile::tempdir().unwrap();
// Helper: stamp a 2048-byte descriptor at `lba` with type + magic.
let stamp = |img: &mut [u8], lba: usize, ty: u8| {
let off = lba * 2048;
img[off] = ty;
img[off + 1..off + 6].copy_from_slice(b"CD001");
};
// Bootable image: PVD @16, El Torito Boot Record @17, terminator @18.
let mut boot = vec![0u8; 2048 * 19];
stamp(&mut boot, 16, 0x01);
stamp(&mut boot, 17, 0x00);
boot[17 * 2048 + 7..17 * 2048 + 7 + EL_TORITO_ID.len()].copy_from_slice(EL_TORITO_ID);
stamp(&mut boot, 18, 0xFF);
let bp = dir.path().join("boot.iso");
std::fs::write(&bp, &boot).unwrap();
let mut f = std::fs::File::open(&bp).unwrap();
assert!(
detect_el_torito(&mut f),
"El Torito boot record should match"
);
// Data/appliance image: PVD @16, terminator @17, no boot record.
let mut data = vec![0u8; 2048 * 18];
stamp(&mut data, 16, 0x01);
stamp(&mut data, 17, 0xFF);
let dp = dir.path().join("data.iso");
std::fs::write(&dp, &data).unwrap();
let mut f2 = std::fs::File::open(&dp).unwrap();
assert!(!detect_el_torito(&mut f2), "data ISO has no boot catalog");
}
#[test]
fn filename_hint_catches_windows_isos() {
use std::path::Path;
assert!(filename_looks_windows(Path::new(
"en-us_windows_11_iot_enterprise_ltsc_2024_x64_dvd.iso"
)));
assert!(filename_looks_windows(Path::new(
"Win10_22H2_English_x64.iso"
)));
assert!(filename_looks_windows(Path::new("winserver2022.iso")));
assert!(!filename_looks_windows(Path::new(
"ubuntu-24.04-desktop.iso"
)));
assert!(!filename_looks_windows(Path::new(
"Rocky-9.4-x86_64-dvd.iso"
)));
}
} }
+8
View File
@@ -20,6 +20,7 @@ pub mod entry;
pub mod introspect; pub mod introspect;
pub mod nfs_share; pub mod nfs_share;
pub mod pxe_logo; pub mod pxe_logo;
pub mod sftp_share;
pub mod smb; pub mod smb;
pub mod smb_share; pub mod smb_share;
pub mod store; pub mod store;
@@ -40,6 +41,13 @@ pub use smb_share::{SmbAddRequest, SmbShare, SmbShareError, SmbShareManager, Smb
// "works in any container" property as SMB, plus support for HTTP // "works in any container" property as SMB, plus support for HTTP
// Range requests because NFSv3 READ3 takes an explicit offset. // Range requests because NFSv3 READ3 takes an explicit offset.
pub use nfs_share::{NfsAddRequest, NfsShare, NfsShareError, NfsShareManager, NfsStream}; pub use nfs_share::{NfsAddRequest, NfsShare, NfsShareError, NfsShareManager, NfsStream};
// v0.5.5: SFTP-over-SSH remote shares via the pure-Rust `russh` +
// `russh-sftp` crates (ring backend — no OpenSSL, no new C deps). Like
// NFS, supports HTTP Range requests because SFTP opens a seekable file
// handle. See crates/iso-store/src/sftp_share.rs.
pub use sftp_share::{
SftpAddRequest, SftpAuthKind, SftpShare, SftpShareError, SftpShareManager, SftpStream,
};
pub use store::{ pub use store::{
generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore, UploadHandle, generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore, UploadHandle,
}; };
+19 -26
View File
@@ -57,14 +57,14 @@
//! UI to ask for. (If a future server needs Kerberos or non-default //! UI to ask for. (If a future server needs Kerberos or non-default
//! uid mapping we can add those, but for ISO read access nobody does.) //! uid mapping we can add those, but for ISO read access nobody does.)
use crate::introspect::{DistroFamily, IntrospectionReport}; use crate::introspect::IntrospectionReport;
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore}; use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
use bytes::Bytes; use bytes::Bytes;
use nfs3_client::tokio::TokioConnector; use nfs3_client::tokio::TokioConnector;
use nfs3_client::Nfs3ConnectionBuilder; use nfs3_client::Nfs3ConnectionBuilder;
use nfs3_types::nfs3::{ use nfs3_types::nfs3::{
self as nfs3, diropargs3, entry3, filename3, nfs_fh3, GETATTR3args, LOOKUP3args, self as nfs3, diropargs3, entry3, filename3, nfs_fh3, GETATTR3args, LOOKUP3args, Nfs3Result,
Nfs3Result, READ3args, READDIR3args, READ3args, READDIR3args,
}; };
use nfs3_types::rpc::{auth_unix, opaque_auth}; use nfs3_types::rpc::{auth_unix, opaque_auth};
use nfs3_types::xdr_codec::Opaque; use nfs3_types::xdr_codec::Opaque;
@@ -220,10 +220,7 @@ impl NfsShareManager {
/// Register an NFS share. Validates, probes connectivity by /// Register an NFS share. Validates, probes connectivity by
/// performing a real MOUNT3 + READDIR3, and registers the /// performing a real MOUNT3 + READDIR3, and registers the
/// resulting ISOs with the store. /// resulting ISOs with the store.
pub async fn add( pub async fn add(&self, req: NfsAddRequest) -> std::result::Result<NfsShare, NfsShareError> {
&self,
req: NfsAddRequest,
) -> std::result::Result<NfsShare, NfsShareError> {
let server = normalize_server(&req.server); let server = normalize_server(&req.server);
let export = req.export.trim().to_string(); let export = req.export.trim().to_string();
if server.is_empty() { if server.is_empty() {
@@ -258,7 +255,9 @@ impl NfsShareManager {
if let Err(e) = self.rescan_inner(&id).await { if let Err(e) = self.rescan_inner(&id).await {
let m = self.get(&id); let m = self.get(&id);
return Err(NfsShareError { return Err(NfsShareError {
error: m.as_ref().and_then(|m| m.last_error.clone()) error: m
.as_ref()
.and_then(|m| m.last_error.clone())
.unwrap_or_else(|| e.to_string()), .unwrap_or_else(|| e.to_string()),
stderr: String::new(), stderr: String::new(),
hint: m.and_then(|m| m.last_hint), hint: m.and_then(|m| m.last_hint),
@@ -333,8 +332,7 @@ impl NfsShareManager {
return Err(Error::Invalid(format!("invalid filename '{filename}'"))); return Err(Error::Invalid(format!("invalid filename '{filename}'")));
} }
let (tx, rx) = let (tx, rx) = tokio::sync::mpsc::channel::<std::io::Result<Bytes>>(STREAM_BUFFER_DEPTH);
tokio::sync::mpsc::channel::<std::io::Result<Bytes>>(STREAM_BUFFER_DEPTH);
let server = share.server.clone(); let server = share.server.clone();
let export = share.export.clone(); let export = share.export.clone();
let port = share.port; let port = share.port;
@@ -358,16 +356,11 @@ impl NfsShareManager {
if let Err(e) = result { if let Err(e) = result {
// Best-effort signal of the error to the consumer. // Best-effort signal of the error to the consumer.
// If the receiver has already dropped we just exit. // If the receiver has already dropped we just exit.
let _ = tx let _ = tx.send(Err(std::io::Error::other(e.to_string()))).await;
.send(Err(std::io::Error::other(e.to_string())))
.await;
} }
}); });
Ok(NfsStream { Ok(NfsStream { rx, _task: task })
rx,
_task: task,
})
} }
// ── internals ───────────────────────────────────────────────────── // ── internals ─────────────────────────────────────────────────────
@@ -399,13 +392,7 @@ impl NfsShareManager {
// Same approach as SMB: no real introspection over the // Same approach as SMB: no real introspection over the
// network in v0.4.67. The boot-entry generator falls back // network in v0.4.67. The boot-entry generator falls back
// to filename-based sanboot detection. // to filename-based sanboot detection.
let report = IntrospectionReport { let report = IntrospectionReport::default();
family: DistroFamily::Unknown,
volume_label: None,
kernel_path: None,
initrd_paths: Vec::new(),
has_boot_wim: false,
};
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report); let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
let source = IsoSource::Nfs { let source = IsoSource::Nfs {
share_id: share.id.clone(), share_id: share.id.clone(),
@@ -988,8 +975,14 @@ mod tests {
// the allow-list and the secure/insecure angle. // the allow-list and the secure/insecure angle.
let h = hint_for("connect failed: MNT3ERR_ACCES").unwrap(); let h = hint_for("connect failed: MNT3ERR_ACCES").unwrap();
let lc = h.to_lowercase(); let lc = h.to_lowercase();
assert!(lc.contains("insecure") || lc.contains("privileged"), "got: {h}"); assert!(
assert!(lc.contains("allow") || lc.contains("permission"), "got: {h}"); lc.contains("insecure") || lc.contains("privileged"),
"got: {h}"
);
assert!(
lc.contains("allow") || lc.contains("permission"),
"got: {h}"
);
} }
#[test] #[test]
File diff suppressed because it is too large Load Diff
+12 -31
View File
@@ -56,7 +56,7 @@
//! streaming. A follow-up release can add libsmbclient-based seek if //! streaming. A follow-up release can add libsmbclient-based seek if
//! a real workload needs it. //! a real workload needs it.
use crate::introspect::{DistroFamily, IntrospectionReport}; use crate::introspect::IntrospectionReport;
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore}; use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
use openpxe_core::{Error, Result}; use openpxe_core::{Error, Result};
use parking_lot::Mutex; use parking_lot::Mutex;
@@ -231,10 +231,7 @@ impl SmbShareManager {
/// Add or refresh a share. Validates the input, writes a creds /// Add or refresh a share. Validates the input, writes a creds
/// file, probes connectivity, and scans for ISOs. /// file, probes connectivity, and scans for ISOs.
pub async fn add( pub async fn add(&self, req: SmbAddRequest) -> std::result::Result<SmbShare, SmbShareError> {
&self,
req: SmbAddRequest,
) -> std::result::Result<SmbShare, SmbShareError> {
let server = normalize_server(&req.server); let server = normalize_server(&req.server);
let share = req.share.trim().trim_start_matches('/').to_string(); let share = req.share.trim().trim_start_matches('/').to_string();
if server.is_empty() { if server.is_empty() {
@@ -309,7 +306,9 @@ impl SmbShareManager {
if let Err(e) = self.rescan_inner(&id).await { if let Err(e) = self.rescan_inner(&id).await {
let m = self.get(&id); let m = self.get(&id);
return Err(SmbShareError { return Err(SmbShareError {
error: m.as_ref().and_then(|m| m.last_error.clone()) error: m
.as_ref()
.and_then(|m| m.last_error.clone())
.unwrap_or_else(|| e.to_string()), .unwrap_or_else(|| e.to_string()),
stderr: String::new(), stderr: String::new(),
hint: m.and_then(|m| m.last_hint), hint: m.and_then(|m| m.last_hint),
@@ -365,11 +364,7 @@ impl SmbShareManager {
/// throttling concurrent smbclients) would need to await without /// throttling concurrent smbclients) would need to await without
/// changing the call sites. /// changing the call sites.
#[allow(clippy::unused_async)] #[allow(clippy::unused_async)]
pub async fn stream_iso( pub async fn stream_iso(&self, share_id: &str, filename: &str) -> Result<SmbStream> {
&self,
share_id: &str,
filename: &str,
) -> Result<SmbStream> {
let share = self let share = self
.get(share_id) .get(share_id)
.ok_or_else(|| Error::Invalid(format!("no such SMB share '{share_id}'")))?; .ok_or_else(|| Error::Invalid(format!("no such SMB share '{share_id}'")))?;
@@ -377,9 +372,7 @@ impl SmbShareManager {
// share root. smbclient itself accepts only filenames at the // share root. smbclient itself accepts only filenames at the
// share root in our `get` form, but belt-and-suspenders. // share root in our `get` form, but belt-and-suspenders.
if filename.contains('/') || filename.contains('\\') || filename.contains("..") { if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return Err(Error::Invalid(format!( return Err(Error::Invalid(format!("invalid filename '{filename}'")));
"invalid filename '{filename}'"
)));
} }
let creds = share let creds = share
.creds_path .creds_path
@@ -470,13 +463,7 @@ impl SmbShareManager {
// and the operator gets *something* bootable. A follow-up // and the operator gets *something* bootable. A follow-up
// release can do a bounded `smbclient get` of the first // release can do a bounded `smbclient get` of the first
// 64 KiB for real detection. // 64 KiB for real detection.
let report = IntrospectionReport { let report = IntrospectionReport::default();
family: DistroFamily::Unknown,
volume_label: None,
kernel_path: None,
initrd_paths: Vec::new(),
has_boot_wim: false,
};
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report); let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
let source = IsoSource::Smb { let source = IsoSource::Smb {
share_id: share.id.clone(), share_id: share.id.clone(),
@@ -543,10 +530,7 @@ impl SmbShareManager {
} else { } else {
String::new() String::new()
}; };
return Err(( return Err((format!("could not exec smbclient: {e}"), stderr));
format!("could not exec smbclient: {e}"),
stderr,
));
} }
}; };
if !output.status.success() { if !output.status.success() {
@@ -757,10 +741,7 @@ fn parse_ls_iso(out: &str) -> Vec<SmbListEntry> {
/// Pre-flight TCP probe to `server:port`. Format matches v0.4.64 NFS /// Pre-flight TCP probe to `server:port`. Format matches v0.4.64 NFS
/// probe so the UI banner reads consistently. /// probe so the UI banner reads consistently.
async fn tcp_probe( async fn tcp_probe(server: &str, port: u16) -> std::result::Result<(), (String, String)> {
server: &str,
port: u16,
) -> std::result::Result<(), (String, String)> {
use tokio::net::TcpStream; use tokio::net::TcpStream;
let addr = format!("{server}:{port}"); let addr = format!("{server}:{port}");
match tokio::time::timeout(PROBE_TIMEOUT, TcpStream::connect(&addr)).await { match tokio::time::timeout(PROBE_TIMEOUT, TcpStream::connect(&addr)).await {
@@ -937,8 +918,8 @@ mod tests {
// exec error in `error` plus an empty `stderr`. The // exec error in `error` plus an empty `stderr`. The
// SmbShareError constructor's hint_for fallback checks error // SmbShareError constructor's hint_for fallback checks error
// too, so this pattern needs to translate as well. // too, so this pattern needs to translate as well.
let h2 = hint_for("could not exec smbclient: No such file or directory (os error 2)") let h2 =
.unwrap(); hint_for("could not exec smbclient: No such file or directory (os error 2)").unwrap();
assert!(h2.contains("smbclient")); assert!(h2.contains("smbclient"));
} }
+152 -33
View File
@@ -24,6 +24,10 @@ use tokio::io::AsyncWriteExt;
/// `nfs3_client` crate (in-process, no subprocess). Same "works in /// `nfs3_client` crate (in-process, no subprocess). Same "works in
/// any container" property as SMB, plus Range requests work because /// any container" property as SMB, plus Range requests work because
/// NFSv3 READ3 takes an explicit offset. /// NFSv3 READ3 takes an explicit offset.
/// `Sftp` (v0.5.5) — remote SFTP-over-SSH share, streamed via the
/// pure-Rust `russh` + `russh-sftp` crates (in-process). Like NFS it
/// supports HTTP Range requests because SFTP opens a seekable file
/// handle (`SSH_FXP_READ` at offset).
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum IsoSource { pub enum IsoSource {
@@ -42,6 +46,13 @@ pub enum IsoSource {
/// Filename at the export root. /// Filename at the export root.
relative_path: String, relative_path: String,
}, },
/// v0.5.5: SFTP-over-SSH via the in-process `russh` + `russh-sftp`
/// crates.
Sftp {
share_id: String,
/// Filename at the export root.
relative_path: String,
},
} }
/// Where the ISO lands in the PXE menu hierarchy. /// Where the ISO lands in the PXE menu hierarchy.
@@ -212,7 +223,8 @@ impl IsoStore {
continue; continue;
} }
if let Ok(text) = tokio::fs::read_to_string(&p).await { if let Ok(text) = tokio::fs::read_to_string(&p).await {
if let Ok(meta) = serde_json::from_str::<IsoMeta>(&text) { if let Ok(mut meta) = serde_json::from_str::<IsoMeta>(&text) {
self.reintrospect_if_stale(&mut meta).await;
self.insert(meta); self.insert(meta);
} }
} }
@@ -220,6 +232,46 @@ impl IsoStore {
Ok(()) Ok(())
} }
/// v0.5.9: re-run introspection on a *local* ISO whose persisted report
/// predates the current logic. ISOs uploaded by an older binary carry a
/// stale family/boot profile — most visibly a Windows 11 ISO tagged
/// `Unknown` before the UDF/El-Torito detection landed, which then shows
/// as "won't boot" forever. Re-probing on startup fixes them in place,
/// no delete-and-re-upload. Bounded: only `Local` sources (we have the
/// bytes locally) below [`introspect::INTROSPECT_REV`], so it runs at
/// most once per ISO per upgrade. The probe reads up to ~64 MiB, so we
/// push it onto the blocking pool to keep the async runtime responsive.
async fn reintrospect_if_stale(&self, meta: &mut IsoMeta) {
if !matches!(meta.source, IsoSource::Local)
|| meta.introspection.introspect_rev >= crate::introspect::INTROSPECT_REV
{
return;
}
let path = self.iso_path(&meta.id);
if !path.exists() {
return;
}
let Ok(fresh) = tokio::task::spawn_blocking(move || introspect(&path)).await else {
tracing::warn!(target: "openpxe::iso", id = %meta.id, "re-introspect task failed");
return;
};
let before = meta.introspection.family;
meta.introspection = fresh;
meta.boot_entries = generate_boot_entries(&meta.id, &meta.filename, &meta.introspection);
if let Err(e) = self.persist_meta(meta).await {
tracing::warn!(target: "openpxe::iso", id = %meta.id, "re-introspect persist: {e}");
return;
}
tracing::info!(
target: "openpxe::iso",
id = %meta.id,
from = ?before,
to = ?meta.introspection.family,
el_torito = meta.introspection.el_torito,
"re-introspected stale ISO metadata"
);
}
fn insert(&self, meta: IsoMeta) { fn insert(&self, meta: IsoMeta) {
self.inner.write().isos.insert(meta.id.clone(), meta); self.inner.write().isos.insert(meta.id.clone(), meta);
} }
@@ -290,20 +342,29 @@ impl IsoStore {
/// SMB sources or when the file is missing. /// SMB sources or when the file is missing.
pub fn iso_path_for(&self, id: &str) -> Option<PathBuf> { pub fn iso_path_for(&self, id: &str) -> Option<PathBuf> {
let meta = self.get(id)?; let meta = self.get(id)?;
self.local_path(&meta)
}
/// Same resolution as [`Self::iso_path_for`], but for a meta the
/// caller already holds — skips the second registry lock + deep
/// clone, which matters on the per-range-request ISO serving path
/// (a sanboot install issues hundreds of those).
#[must_use]
pub fn local_path(&self, meta: &IsoMeta) -> Option<PathBuf> {
match &meta.source { match &meta.source {
IsoSource::Local => { IsoSource::Local => {
let path = self.iso_path(id); let path = self.iso_path(&meta.id);
if path.exists() { if path.exists() {
Some(path) Some(path)
} else { } else {
None None
} }
} }
// SMB and NFS sources have no local path — they're // SMB, NFS, and SFTP sources have no local path — they're
// streamed in-process. Callers must inspect the source // streamed in-process. Callers must inspect the source
// kind first and dispatch to the appropriate share // kind first and dispatch to the appropriate share
// manager. // manager.
IsoSource::Smb { .. } | IsoSource::Nfs { .. } => None, IsoSource::Smb { .. } | IsoSource::Nfs { .. } | IsoSource::Sftp { .. } => None,
} }
} }
@@ -363,9 +424,9 @@ impl IsoStore {
pub fn drop_external_source(&self, share_id: &str) { pub fn drop_external_source(&self, share_id: &str) {
let mut g = self.inner.write(); let mut g = self.inner.write();
g.isos.retain(|_, m| match &m.source { g.isos.retain(|_, m| match &m.source {
IsoSource::Smb { share_id: sid, .. } | IsoSource::Nfs { share_id: sid, .. } => { IsoSource::Smb { share_id: sid, .. }
sid != share_id | IsoSource::Nfs { share_id: sid, .. }
} | IsoSource::Sftp { share_id: sid, .. } => sid != share_id,
IsoSource::Local => true, IsoSource::Local => true,
}); });
} }
@@ -546,22 +607,22 @@ fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> V
.clone() .clone()
.unwrap_or_else(|| filename.to_string()); .unwrap_or_else(|| filename.to_string());
match r.family { match r.family {
DistroFamily::WindowsPe if r.has_boot_wim => { DistroFamily::WindowsPe => {
// Standard wimboot chain. Paths are in-ISO; the HTTP layer maps // v0.5.8: boot Windows directly via iPXE HTTP sanboot. iPXE
// `iso/<id>/<path>` to on-disk extraction via ISO9660 lookup. // exposes the raw ISO as an emulated CD backed by on-demand
let base = format!("iso/{id}"); // HTTP range reads, and Windows Setup boots from it. This
// replaces the old wimboot+SMB chain, which (a) needed an SMB
// server the host often can't provide (port 445 collisions),
// (b) served in-ISO files via an ISO9660 lookup that failed on
// UDF-only Windows 11 ISOs, and (c) required an operator
// toggle. sanboot needs none of that — just the HTTP port,
// which works in any environment. The unmodified, stock ISO is
// served at iso/<id>.iso; nothing is injected into Windows.
vec![BootEntry { vec![BootEntry {
id: format!("{id}-winpe"), id: format!("{id}-windows"),
title: format!("{title} (Windows / wimboot)"), title: format!("{title} (Windows)"),
kind: BootKind::Wimboot { kind: BootKind::SanBootIso {
wimboot_url: "ipxe/wimboot".to_string(), iso_url: format!("iso/{id}.iso"),
files: vec![
("bootmgr".into(), format!("{base}/bootmgr")),
("bootmgr.efi".into(), format!("{base}/bootmgr.efi")),
("bcd".into(), format!("{base}/boot/bcd")),
("boot.sdi".into(), format!("{base}/boot/boot.sdi")),
("boot.wim".into(), format!("{base}/sources/boot.wim")),
],
}, },
}] }]
} }
@@ -587,15 +648,33 @@ fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> V
}] }]
} }
_ => { _ => {
// Last-resort SAN boot. Won't work for large modern ISOs, but // No Windows-install media and no Linux kernel/initrd. Decide
// lets the ISO at least appear in the menu. // whether the ISO is bootable at all (v0.6.0):
// * `el_torito` — it carries a boot catalog, so iPXE sanboots
// the raw image as an emulated CD: BSDs, ESXi/VMvisor
// installers, firmware tools, custom spins. The emulated CD
// is backed by HTTP range reads, so ISO size is a non-issue
// (this is the same path Windows uses since v0.5.8) — hence
// no more "may fail for >1GiB ISOs" disclaimer.
// * `introspect_rev == 0` — a remote-share ISO we couldn't
// introspect (SMB/NFS/SFTP listings don't seek into the ISO).
// Offer sanboot optimistically rather than hide a
// likely-bootable installer.
// Otherwise it's a local image we *did* introspect and found to
// carry no boot catalog — a data/appliance ISO (e.g. a VMware
// vCenter Server Appliance bundle). It genuinely cannot boot, so
// we expose no menu entry; the dashboard flags it instead.
if r.el_torito || r.introspect_rev == 0 {
vec![BootEntry { vec![BootEntry {
id: format!("{id}-sanboot"), id: format!("{id}-sanboot"),
title: format!("{title} (SAN boot — may fail for >1GiB ISOs)"), title,
kind: BootKind::SanBootIso { kind: BootKind::SanBootIso {
iso_url: format!("iso/{id}.iso"), iso_url: format!("iso/{id}.iso"),
}, },
}] }]
} else {
Vec::new()
}
} }
} }
} }
@@ -659,13 +738,59 @@ mod tests {
// good. // good.
let s = linux_cmdline(DistroFamily::DebianUbuntu, "ubuntu-24-04"); let s = linux_cmdline(DistroFamily::DebianUbuntu, "ubuntu-24-04");
assert!(s.contains("boot=casper"), "{s}"); assert!(s.contains("boot=casper"), "{s}");
assert!(s.contains("iso-url=${base-url}/iso/ubuntu-24-04.iso"), "{s}"); assert!(
s.contains("iso-url=${base-url}/iso/ubuntu-24-04.iso"),
"{s}"
);
assert!(s.contains("ds=nocloud"), "{s}"); assert!(s.contains("ds=nocloud"), "{s}");
assert!(s.contains("ip=dhcp"), "{s}"); assert!(s.contains("ip=dhcp"), "{s}");
assert!(!s.contains("netboot=url"), "legacy option leaked: {s}"); assert!(!s.contains("netboot=url"), "legacy option leaked: {s}");
assert!(!s.contains(" --- "), "stray ---: {s}"); assert!(!s.contains(" --- "), "stray ---: {s}");
} }
#[test]
fn boot_entries_respect_el_torito_and_source() {
use crate::introspect::INTROSPECT_REV;
// ESXi / VMvisor installer shape: bootable (carries an El Torito
// catalog) but not classifiable as Windows or Linux. Must yield a
// single sanboot entry so it's selectable + boots via emulated CD.
let esxi = IntrospectionReport {
family: DistroFamily::Unknown,
volume_label: Some("ESXI-7.0U3".into()),
el_torito: true,
introspect_rev: INTROSPECT_REV,
..Default::default()
};
let e = generate_boot_entries("esxi", "VMware-VMvisor-Installer-7.0U3n.iso", &esxi);
assert_eq!(e.len(), 1, "ESXi should get exactly one boot entry");
assert!(matches!(e[0].kind, BootKind::SanBootIso { .. }));
// Clean title — no stale ">1GiB may fail" disclaimer.
assert!(!e[0].title.contains("may fail"), "title: {}", e[0].title);
// VCSA / data-appliance shape: locally introspected (rev set), no
// boot catalog, not Windows/Linux. Genuinely unbootable → no entry,
// so it stays out of the iPXE menu (the dashboard flags it instead).
let vcsa = IntrospectionReport {
family: DistroFamily::Unknown,
el_torito: false,
introspect_rev: INTROSPECT_REV,
..Default::default()
};
assert!(
generate_boot_entries("vcsa", "VMware-VCSA-all-8.0.iso", &vcsa).is_empty(),
"data/appliance ISO must produce no boot entry"
);
// Remote-share ISO: never introspected (rev 0, no random access over
// SMB/NFS/SFTP). Assume bootable and offer sanboot rather than hide a
// likely-bootable installer.
let remote = IntrospectionReport::default();
let r = generate_boot_entries("remote", "unknown-remote.iso", &remote);
assert_eq!(r.len(), 1, "remote (uninspected) ISO keeps a sanboot entry");
assert!(matches!(r[0].kind, BootKind::SanBootIso { .. }));
}
fn fake_meta(id: &str) -> IsoMeta { fn fake_meta(id: &str) -> IsoMeta {
IsoMeta { IsoMeta {
id: id.into(), id: id.into(),
@@ -673,13 +798,7 @@ mod tests {
size_bytes: 0, size_bytes: 0,
sha256_hex: None, sha256_hex: None,
uploaded_at: OffsetDateTime::now_utc(), uploaded_at: OffsetDateTime::now_utc(),
introspection: IntrospectionReport { introspection: IntrospectionReport::default(),
family: DistroFamily::Unknown,
volume_label: None,
kernel_path: None,
initrd_paths: vec![],
has_boot_wim: false,
},
boot_entries: vec![], boot_entries: vec![],
source: IsoSource::Local, source: IsoSource::Local,
password_hash: None, password_hash: None,
+134 -9
View File
@@ -9,7 +9,7 @@ use openpxe_core::{
}; };
use openpxe_dhcp_proxy::DhcpProxyServer; use openpxe_dhcp_proxy::DhcpProxyServer;
use openpxe_http_api::{build_router, AppState}; use openpxe_http_api::{build_router, AppState};
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager}; use openpxe_iso_store::{IsoStore, NfsShareManager, SftpShareManager, SmbManager, SmbShareManager};
use openpxe_tftp::TftpServer; use openpxe_tftp::TftpServer;
use std::net::{Ipv4Addr, SocketAddr}; use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf; use std::path::PathBuf;
@@ -59,11 +59,10 @@ async fn main() -> anyhow::Result<()> {
init_tracing(log_bus.clone()); init_tracing(log_bus.clone());
let cli = Cli::parse(); let cli = Cli::parse();
let mut config = match &cli.config { // v0.5.4: layered load via figment — defaults → optional TOML → env.
Some(p) if p.exists() => Config::from_toml_file(p)?, // The OPENPXE_* env layer keeps the historical flat names (see
_ => Config::default(), // `Config::load`), so existing deployments are unaffected.
}; let config = Config::load(cli.config.as_deref())?;
config.apply_env();
// Dispatch subcommands before bringing up the server. // Dispatch subcommands before bringing up the server.
if let Some(cmd) = cli.command { if let Some(cmd) = cli.command {
@@ -95,14 +94,19 @@ async fn main() -> anyhow::Result<()> {
} }
}, },
}; };
let public_base_url = format!("http://{our_ip}"); // v0.5.6: the advertised base URL must carry the HTTP port. Every
// client-facing URL (the DHCP-proxy iPXE filename, UEFI HTTP boot,
// and the menu's kernel/initrd/ISO links) is derived from this one
// string, so omitting the port silently pointed PXE clients at :80 —
// breaking every non-80 deployment (e.g. the Unraid template's 4200,
// chosen to dodge the webGUI). See `build_public_base_url`.
let public_base_url = build_public_base_url(our_ip, config.server.http_port);
let iso_store = IsoStore::new(config.paths.iso_dir.clone()); let iso_store = IsoStore::new(config.paths.iso_dir.clone());
iso_store.load_from_disk().await?; iso_store.load_from_disk().await?;
// v0.5.2: unattended answer-file store (Kickstart/Preseed/Autoinstall/ // v0.5.2: unattended answer-file store (Kickstart/Preseed/Autoinstall/
// Windows answer files). Separate directory from the ISO store. // Windows answer files). Separate directory from the ISO store.
let unattended = let unattended = openpxe_iso_store::UnattendedStore::new(config.paths.unattended_dir.clone());
openpxe_iso_store::UnattendedStore::new(config.paths.unattended_dir.clone());
if let Err(e) = unattended.load_from_disk().await { if let Err(e) = unattended.load_from_disk().await {
tracing::warn!( tracing::warn!(
target: "openpxe::unattended", target: "openpxe::unattended",
@@ -156,6 +160,19 @@ async fn main() -> anyhow::Result<()> {
); );
} }
// v0.5.5: SFTP-over-SSH share manager — pure-Rust in-process
// consumer via `russh` + `russh-sftp` (ring backend, no OpenSSL).
// The third remote-library protocol alongside SMB/NFS; like NFS it
// works in any container (no subprocess, no kernel mount) and
// supports HTTP Range requests because SFTP file handles seek.
let sftp_shares = SftpShareManager::new(&config.paths.work_dir, iso_store.clone());
if let Err(e) = sftp_shares.load_and_rescan().await {
tracing::warn!(
target: "openpxe::sftp",
"could not reload SFTP shares on startup: {e}"
);
}
// Sniff network details for the Network tab. None of these are // Sniff network details for the Network tab. None of these are
// required for PXE to work — they're informational, surfaced in the // required for PXE to work — they're informational, surfaced in the
// UI so an operator doesn't have to drop to a shell to find their // UI so an operator doesn't have to drop to a shell to find their
@@ -167,6 +184,10 @@ async fn main() -> anyhow::Result<()> {
"network info" "network info"
); );
// v0.7.1: boot rules are shared between the HTTP layer (target rules,
// webhook, the editor API) and the DHCP proxy (driver-mode pins).
let boot_rules = openpxe_core::BootRulesStore::load_or_default(&config.paths.work_dir);
let state = AppState { let state = AppState {
iso_store: iso_store.clone(), iso_store: iso_store.clone(),
clients: clients.clone(), clients: clients.clone(),
@@ -174,7 +195,10 @@ async fn main() -> anyhow::Result<()> {
queue: queue.clone(), queue: queue.clone(),
hosts: hosts.clone(), hosts: hosts.clone(),
boot_log: boot_log.clone(), boot_log: boot_log.clone(),
boot_rules: boot_rules.clone(),
boot_tokens: openpxe_core::BootTokens::new(),
branding: branding.clone(), branding: branding.clone(),
pxe_bg_cache: openpxe_http_api::state::PxeBgCache::default(),
admin: admin.clone(), admin: admin.clone(),
sessions: sessions.clone(), sessions: sessions.clone(),
sso: sso.clone(), sso: sso.clone(),
@@ -184,12 +208,14 @@ async fn main() -> anyhow::Result<()> {
smb: Some(smb.clone()), smb: Some(smb.clone()),
smb_shares: smb_shares.clone(), smb_shares: smb_shares.clone(),
nfs_shares: nfs_shares.clone(), nfs_shares: nfs_shares.clone(),
sftp_shares: sftp_shares.clone(),
unattended: unattended.clone(), unattended: unattended.clone(),
uploads: openpxe_http_api::uploads::UploadSessions::default(), uploads: openpxe_http_api::uploads::UploadSessions::default(),
log_bus: log_bus.clone(), log_bus: log_bus.clone(),
started_at: time::OffsetDateTime::now_utc(), started_at: time::OffsetDateTime::now_utc(),
public_base_url: public_base_url.clone(), public_base_url: public_base_url.clone(),
nic_name: net.nic_name, nic_name: net.nic_name,
nic_link: net.nic_link,
subnet_mask: net.subnet_mask, subnet_mask: net.subnet_mask,
gateway: net.gateway, gateway: net.gateway,
}; };
@@ -211,11 +237,28 @@ async fn main() -> anyhow::Result<()> {
Ok::<_, anyhow::Error>(()) Ok::<_, anyhow::Error>(())
}); });
// v0.7.0: TFTP names that aren't embedded assets get a dynamic
// renderer — `grub.cfg` for the Secure Boot shim+GRUB chain is
// generated from the live boot-entry list on every fetch, so menu
// changes apply without restart.
let grub_isos = iso_store.clone();
let grub_base = public_base_url.clone();
let tftp_dynamic: openpxe_tftp::DynamicAsset = std::sync::Arc::new(move |name: &str| {
if name == "grub.cfg" || name.starts_with("grub.cfg-") {
Some(
openpxe_http_api::grub_script::render_grub_menu(&grub_isos.list(), &grub_base)
.into_bytes(),
)
} else {
None
}
});
let tftp = TftpServer::new( let tftp = TftpServer::new(
config.server.tftp_bind, config.server.tftp_bind,
config.server.tftp_port, config.server.tftp_port,
clients.clone(), clients.clone(),
metrics.clone(), metrics.clone(),
Some(tftp_dynamic),
); );
let tftp_task = tokio::spawn(tftp.run()); let tftp_task = tokio::spawn(tftp.run());
@@ -229,6 +272,10 @@ async fn main() -> anyhow::Result<()> {
public_base_url.clone(), public_base_url.clone(),
clients.clone(), clients.clone(),
metrics.clone(), metrics.clone(),
// v0.7.1: learned driver modes persist next to the other
// state files, so a machine walks the ladder once *ever*.
openpxe_dhcp_proxy::DriverEscalation::load_or_default(&config.paths.work_dir),
boot_rules.clone(),
); );
tokio::spawn(s.run()) tokio::spawn(s.run())
} }
@@ -333,6 +380,20 @@ async fn seed_from_dir(
/// a loopback address (which would give every PXE client an unreachable /// a loopback address (which would give every PXE client an unreachable
/// `http://127.0.0.1/...`). Users in multi-homed setups should set /// `http://127.0.0.1/...`). Users in multi-homed setups should set
/// `OPENPXE_PUBLIC_IP` explicitly. /// `OPENPXE_PUBLIC_IP` explicitly.
/// Build the base URL advertised to PXE clients. The port is included
/// unless it's the HTTP default (80), keeping the common case clean
/// (`http://10.0.0.5`) while a remapped port (`http://10.0.0.5:4200`)
/// stays reachable. This is the single source of truth for every
/// client-facing URL — the DHCP-proxy iPXE filename, UEFI HTTP boot, and
/// the boot menu's kernel/initrd/ISO links all derive from it.
fn build_public_base_url(ip: Ipv4Addr, http_port: u16) -> String {
if http_port == 80 {
format!("http://{ip}")
} else {
format!("http://{ip}:{http_port}")
}
}
fn detect_primary_ipv4() -> Option<Ipv4Addr> { fn detect_primary_ipv4() -> Option<Ipv4Addr> {
// First try: route to the public internet. `UdpSocket::connect` to a // First try: route to the public internet. `UdpSocket::connect` to a
// well-known external address causes the OS to populate `local_addr` // well-known external address causes the OS to populate `local_addr`
@@ -388,6 +449,12 @@ struct NetworkInfo {
nic_name: String, nic_name: String,
subnet_mask: String, subnet_mask: String,
gateway: String, gateway: String,
/// v0.7.2: physical link summary for the Network tab — operstate,
/// negotiated speed/duplex, and the port's own MAC. Helps operators
/// in multi-NIC / trunked environments confirm *which* port the PXE
/// server actually answers on. Empty when sysfs isn't available
/// (non-Linux dev builds) or the NIC wasn't identified.
nic_link: String,
} }
/// Best-effort population of the Network tab's read-only fields. We shell /// Best-effort population of the Network tab's read-only fields. We shell
@@ -448,9 +515,44 @@ fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo {
} }
} }
info.nic_link = detect_link_info(&info.nic_name);
info info
} }
/// v0.7.2: read the NIC's physical link details from sysfs. Every field
/// is optional — virtual NICs report no speed (`-1` or absent), and
/// non-Linux dev machines have no `/sys/class/net` at all — so the
/// result is whatever could be read, joined human-readably, or empty.
fn detect_link_info(nic: &str) -> String {
if nic.is_empty() {
return String::new();
}
let read = |file: &str| {
std::fs::read_to_string(format!("/sys/class/net/{nic}/{file}"))
.map(|s| s.trim().to_string())
.unwrap_or_default()
};
let mut parts: Vec<String> = Vec::new();
let state = read("operstate");
if !state.is_empty() {
parts.push(format!("link {state}"));
}
let speed = read("speed");
if !speed.is_empty() && speed != "-1" {
parts.push(format!("{speed} Mb/s"));
}
let duplex = read("duplex");
if !duplex.is_empty() && duplex != "unknown" {
parts.push(format!("{duplex} duplex"));
}
let mac = read("address");
if !mac.is_empty() {
parts.push(format!("port {mac}"));
}
parts.join(" · ")
}
fn prefix_to_dotted(prefix: u8) -> String { fn prefix_to_dotted(prefix: u8) -> String {
let prefix = prefix.min(32); let prefix = prefix.min(32);
let mask: u32 = if prefix == 0 { let mask: u32 = if prefix == 0 {
@@ -466,3 +568,26 @@ fn prefix_to_dotted(prefix: u8) -> String {
mask & 0xff mask & 0xff
) )
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn public_base_url_includes_non_default_port() {
// The v0.5.6 regression guard: a remapped HTTP port (e.g. the
// Unraid template's 4200) MUST appear in the advertised URL, or
// PXE clients fetch :80 — the wrong service — and boot fails.
let ip: Ipv4Addr = "192.168.1.49".parse().unwrap();
assert_eq!(build_public_base_url(ip, 4200), "http://192.168.1.49:4200");
assert_eq!(build_public_base_url(ip, 8080), "http://192.168.1.49:8080");
}
#[test]
fn public_base_url_omits_default_port() {
// Port 80 stays clean (no `:80`) so the common case reads nicely
// and matches what every browser/iPXE assumes by default.
let ip: Ipv4Addr = "10.0.0.5".parse().unwrap();
assert_eq!(build_public_base_url(ip, 80), "http://10.0.0.5");
}
}
+1 -1
View File
@@ -14,4 +14,4 @@
pub mod server; pub mod server;
pub use server::TftpServer; pub use server::{DynamicAsset, TftpServer};
+53 -17
View File
@@ -7,12 +7,12 @@
//! `tftpd`/`in.tftpd` works and is why TFTP is awkward behind stateful NAT: //! `tftpd`/`in.tftpd` works and is why TFTP is awkward behind stateful NAT:
//! the ephemeral ports must be reachable from the client. //! the ephemeral ports must be reachable from the client.
//! //!
//! We only serve files from `openpxe_ipxe_assets::asset_bytes` — that is, //! We only serve files from `openpxe_ipxe_assets::asset_slice` — that is,
//! the bundled iPXE binaries and wimboot. No filesystem is ever opened, so //! the bundled iPXE binaries and wimboot. No filesystem is ever opened, so
//! `../` path traversal attempts simply return ENOENT. //! `../` path traversal attempts simply return ENOENT.
use openpxe_core::{ClientEvent, ClientRegistry}; use openpxe_core::{ClientEvent, ClientRegistry};
use openpxe_ipxe_assets::asset_bytes; use openpxe_ipxe_assets::asset_slice;
use socket2::{Domain, Protocol, Socket, Type}; use socket2::{Domain, Protocol, Socket, Type};
use std::net::{IpAddr, SocketAddr}; use std::net::{IpAddr, SocketAddr};
use std::sync::Arc; use std::sync::Arc;
@@ -21,6 +21,7 @@ use tokio::net::UdpSocket;
// TFTP opcodes. // TFTP opcodes.
const OP_RRQ: u16 = 1; const OP_RRQ: u16 = 1;
const OP_WRQ: u16 = 2;
const OP_DATA: u16 = 3; const OP_DATA: u16 = 3;
const OP_ACK: u16 = 4; const OP_ACK: u16 = 4;
const OP_ERROR: u16 = 5; const OP_ERROR: u16 = 5;
@@ -31,11 +32,19 @@ const ERR_NOT_DEFINED: u16 = 0;
const ERR_FILE_NOT_FOUND: u16 = 1; const ERR_FILE_NOT_FOUND: u16 = 1;
const ERR_ILLEGAL_OP: u16 = 4; const ERR_ILLEGAL_OP: u16 = 4;
/// Server-rendered TFTP content for names that aren't embedded assets —
/// e.g. `grub.cfg` for the signed shim+GRUB Secure Boot chain (v0.7.0),
/// which is generated from the live boot-entry list per fetch. Kept as a
/// closure so this crate stays decoupled from the ISO store; the binary
/// wires it up in `main`.
pub type DynamicAsset = Arc<dyn Fn(&str) -> Option<Vec<u8>> + Send + Sync>;
pub struct TftpServer { pub struct TftpServer {
bind: IpAddr, bind: IpAddr,
port: u16, port: u16,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics, metrics: openpxe_core::Metrics,
dynamic: Option<DynamicAsset>,
} }
impl TftpServer { impl TftpServer {
@@ -44,12 +53,14 @@ impl TftpServer {
port: u16, port: u16,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics, metrics: openpxe_core::Metrics,
dynamic: Option<DynamicAsset>,
) -> Self { ) -> Self {
Self { Self {
bind, bind,
port, port,
clients, clients,
metrics, metrics,
dynamic,
} }
} }
@@ -71,8 +82,11 @@ impl TftpServer {
let clients = clients.clone(); let clients = clients.clone();
let metrics = metrics.clone(); let metrics = metrics.clone();
let bind_ip = self.bind; let bind_ip = self.bind;
let dynamic = self.dynamic.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = handle_rrq(data, from, bind_ip, clients, metrics.clone()).await { if let Err(e) =
handle_rrq(data, from, bind_ip, clients, metrics.clone(), dynamic).await
{
metrics.record_tftp_err(); metrics.record_tftp_err();
tracing::warn!(target: "openpxe::tftp", peer=%from, "handler error: {e}"); tracing::warn!(target: "openpxe::tftp", peer=%from, "handler error: {e}");
} }
@@ -87,18 +101,45 @@ async fn handle_rrq(
bind_ip: IpAddr, bind_ip: IpAddr,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics, metrics: openpxe_core::Metrics,
dynamic: Option<DynamicAsset>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let Some(req) = parse_rrq(&packet) else { let Some(req) = parse_rrq(&packet) else {
// Not a well-formed RRQ. A WRQ deserves an explicit refusal —
// legacy clients retry a silently-dropped write until they time
// out; an ERROR packet fails them fast with a readable reason.
if packet.len() >= 2 && u16::from_be_bytes([packet[0], packet[1]]) == OP_WRQ {
let sock = bind_udp(bind_ip, 0)?;
let _ = send_error(&sock, peer, ERR_ILLEGAL_OP, "writes not supported").await;
}
return Ok(()); return Ok(());
}; };
let Request { let Request {
filename, options, .. filename,
mode,
options,
} = req; } = req;
// Per-transfer ephemeral socket. // Per-transfer ephemeral socket.
let sock = bind_udp(bind_ip, 0)?; let sock = bind_udp(bind_ip, 0)?;
let Some(file_bytes) = asset_bytes(&filename) else { // We serve binary boot artifacts; netascii line-ending translation
// would corrupt them. Refuse loudly instead of timing out silently —
// matters for legacy clients that default to netascii.
if !mode.eq_ignore_ascii_case("octet") {
let _ = send_error(&sock, peer, ERR_NOT_DEFINED, "only octet mode is supported").await;
tracing::info!(target: "openpxe::tftp", peer=%peer, %mode, "rejected non-octet transfer");
return Ok(());
}
// Embedded assets first; otherwise the dynamic renderer (server-
// generated content like the Secure Boot chain's grub.cfg, v0.7.0).
let resolved = asset_slice(&filename).or_else(|| {
dynamic
.as_ref()
.and_then(|f| f(&filename))
.map(std::borrow::Cow::Owned)
});
let Some(file_bytes) = resolved else {
let _ = send_error(&sock, peer, ERR_FILE_NOT_FOUND, "no such file").await; let _ = send_error(&sock, peer, ERR_FILE_NOT_FOUND, "no such file").await;
tracing::info!(target: "openpxe::tftp", peer=%peer, file=%filename, "404"); tracing::info!(target: "openpxe::tftp", peer=%peer, file=%filename, "404");
clients.record( clients.record(
@@ -262,7 +303,6 @@ async fn handle_rrq(
#[derive(Debug)] #[derive(Debug)]
struct Request { struct Request {
filename: String, filename: String,
#[allow(dead_code)]
mode: String, mode: String,
options: Vec<(String, String)>, options: Vec<(String, String)>,
} }
@@ -393,17 +433,13 @@ fn bind_udp(bind: IpAddr, port: u16) -> anyhow::Result<UdpSocket> {
Ok(UdpSocket::from_std(std_sock)?) Ok(UdpSocket::from_std(std_sock)?)
} }
#[allow(dead_code)] /// Pure-logic mirror of `handle_rrq`'s windowing math, exercised by the
const _UNUSED: (u16, u16) = (ERR_NOT_DEFINED, ERR_ILLEGAL_OP); /// unit tests below. Given a position in the file and the window, return
/// the (block_no, chunk_len) list this window will emit — tested against
/// Pure-logic helper used by the unit tests below and (in a refactor) by /// edge cases (exact-blksize tail, short tail, single-block window,
/// `handle_rrq`. Given a position in the file and the window, return the /// block-number wraparound).
/// (block_no, chunk_len) list this window will emit. Useful as a sanity #[cfg(test)]
/// check that our windowing math matches the wire behavior the spec fn plan_window(
/// requires — tested against edge cases (exact-blksize tail, short tail,
/// single-block window).
#[must_use]
pub fn plan_window(
total: usize, total: usize,
offset: usize, offset: usize,
blksize: usize, blksize: usize,
+19
View File
@@ -304,6 +304,14 @@ button.ghost { background: transparent; color: var(--fg); border: 1px solid var(
button.ghost:hover { background: var(--bg-panel-2); color: var(--fg); } button.ghost:hover { background: var(--bg-panel-2); color: var(--fg); }
button.danger { background: transparent; color: var(--err); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); } button.danger { background: transparent; color: var(--err); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); }
button.danger:hover { background: color-mix(in srgb, var(--err) 14%, transparent); color: var(--err); } button.danger:hover { background: color-mix(in srgb, var(--err) 14%, transparent); color: var(--err); }
/* v0.5.3: unified spacing for a card's primary action button(s). Any
button that sits as a direct child of a card body (Save, Bind, Add,
Launch, ) gets the same gap above it so it never butts against the
form. Inline buttons inside table rows / toolbars / logo slots /
modal action bars are nested deeper, so the `>` keeps them untouched.
Adjacent action buttons on one row (e.g. Save + Send test) share the
margin and stay aligned. */
.card .body > button { margin-top: 16px; }
label.field { label.field {
display: grid; gap: 4px; margin-bottom: 14px; display: grid; gap: 4px; margin-bottom: 14px;
@@ -904,3 +912,14 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px;
} }
.modal-actions .submit { width: auto; padding: 8px 18px; } .modal-actions .submit { width: auto; padding: 8px 18px; }
/* v0.7.2: a label.field directly followed by the card's action button
stacked its own 14px bottom margin onto the button's 16px top margin
(30px total) visible on Queue "Launch for all waiting" and the
Network "Save". Collapse the doubled gap so every primary action sits
the same 16px below its form. */
.card .body > label.field:has(+ button) { margin-bottom: 0; }
/* v0.7.2: inline list filter (Available images / Unattended files). */
.list-search { padding: 14px 16px 0; }
.list-search input { width: 100%; }
+397 -75
View File
@@ -160,20 +160,36 @@
// tint borrowed from Bootimus v0.1.62. Returns {ok, reason}. // tint borrowed from Bootimus v0.1.62. Returns {ok, reason}.
function bootability(iso, settings) { function bootability(iso, settings) {
const fam = iso.introspection.family; const fam = iso.introspection.family;
const isWin = fam === 'windows_pe'; // v0.5.8: Windows ISOs boot via iPXE HTTP sanboot of the raw image —
if (isWin && !settings.windows_enabled) { // no Settings toggle, no SMB, no size limit. Always bootable.
return { ok: false, reason: 'Windows boot disabled in Settings' }; if (fam === 'windows_pe') {
}
if (!isWin && !iso.introspection.kernel_path && fam !== 'windows_pe') {
// Linux without a detected kernel falls through to sanboot which
// rarely works for >1 GiB ISOs.
if (iso.size_bytes > 1.5 * 1024 * 1024 * 1024) {
return { ok: false, reason: 'no kernel/initrd detected; ISO too large for sanboot fallback' };
}
return { ok: true, warn: 'no kernel detected — sanboot fallback may not work' };
}
return { ok: true }; return { ok: true };
} }
// Linux with a detected kernel/initrd — direct kernel+initrd boot.
if (iso.introspection.kernel_path) {
return { ok: true };
}
// v0.5.9: any other ISO that carries an El Torito boot catalog is
// bootable via iPXE sanboot (emulated CD) — BSDs, ESXi, firmware
// tools, custom Linux spins. This replaces the old "> 1.5 GB ⇒
// unbootable" size guess with the authoritative on-disk boot signal,
// so a large bootable ISO is no longer mislabeled and a Windows ISO
// re-introspected on upgrade lights up correctly.
if (iso.introspection.el_torito) {
return { ok: true, warn: 'generic bootable ISO — boots via sanboot (emulated CD)' };
}
// Remote-share ISOs aren't introspected (no random access over the
// network), so el_torito is unknown — assume bootable and let sanboot
// try rather than cry wolf.
const remote = iso.source && iso.source.kind && iso.source.kind !== 'local';
if (remote) {
return { ok: true, warn: 'remote ISO — not introspected; sanboot is attempted at boot' };
}
// Local ISO with no Windows/Linux boot files and no El Torito catalog:
// a data/appliance image (e.g. a VMware vCenter bundle), not a bootable
// installer.
return { ok: false, reason: 'data/appliance ISO — no El Torito boot catalog and no Windows/Linux installer files, so it cant be PXE-booted' };
}
// v0.5.2: pretty label for an unattended file's detected kind. // v0.5.2: pretty label for an unattended file's detected kind.
function unattendedKindLabel(k) { function unattendedKindLabel(k) {
@@ -183,6 +199,51 @@
})[k] || (k || 'Unknown'); })[k] || (k || 'Unknown');
} }
// v0.7.2: compact read-out of saved group rules — created from the
// unified "Pin MAC" form on the Hosts tab (a prefix or an architecture
// there saves a rule instead of a pin). First match wins, top to
// bottom. The boot-decision webhook remains available via the API
// (/api/boot-rules `webhook_url`) but no longer has a UI knob.
function groupRulesCard(cfg, targetOptions) {
const rules = (cfg && cfg.rules) || [];
if (!rules.length) return null;
const titleFor = id => {
const t = targetOptions.find(x => x.id === id);
return t ? t.title : id;
};
const modeLabel = {firmware:'Firmware NIC', builtin:'iPXE drivers', shim:'Secure Boot (shim)'};
const rows = rules.map((r, i) => el('tr', r.enabled === false ? {style:'opacity:.5'} : {}, [
el('td', {class:'mono'}, r.mac_prefix || el('span', {class:'tag'}, 'any MAC')),
el('td', {}, r.arch || el('span', {class:'tag'}, 'any arch')),
el('td', {}, r.target ? titleFor(r.target) : el('span', {class:'tag'}, '—')),
el('td', {}, r.driver_mode
? el('span', {class:'tag accent'}, modeLabel[r.driver_mode] || r.driver_mode)
: el('span', {class:'tag'}, 'auto')),
el('td', {}, r.note || ''),
el('td', {style:'text-align:right'},
el('button', {class:'danger', onclick: async () => {
if (!confirm('Remove this group rule?')) return;
const fresh = await getJSON('/api/boot-rules').catch(() => ({rules: [], webhook_url: ''}));
(fresh.rules = fresh.rules || []).splice(i, 1);
await putJSON('/api/boot-rules', fresh);
render('hosts');
}}, 'Remove')),
]));
return el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Group rules'),
el('span', {class:'sub'}, 'first match wins · checked top to bottom'),
]),
el('table', {}, [
el('thead', {}, el('tr', {}, [
el('th',{},'MAC prefix'), el('th',{},'Arch'), el('th',{},'Target'),
el('th',{},'Boot binary'), el('th',{},'Note'), el('th',{},''),
])),
el('tbody', {}, rows),
]),
]);
}
// v0.5.2: build the shared "deployment profile" field group — auto // v0.5.2: build the shared "deployment profile" field group — auto
// hostname, auto IP, and an unattended-file picker — reused by the // hostname, auto IP, and an unattended-file picker — reused by the
// Hosts pin form and the Queue "Profile" modal. `files` is the // Hosts pin form and the Queue "Profile" modal. `files` is the
@@ -288,14 +349,23 @@
el('div', {class: 'card'}, el('div', {class: 'stat'}, [ el('div', {class: 'card'}, el('div', {class: 'stat'}, [
el('div', {class: 'label'}, 'Images available'), el('div', {class: 'label'}, 'Images available'),
el('div', {class: 'value'}, String(isos.length)), el('div', {class: 'value'}, String(isos.length)),
el('div', {class: 'trend'}, el('div', {class: 'trend'}, (() => {
isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' + // v0.5.9: count families honestly. Anything that isn't a known
isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux · ' + // Linux family or Windows lands in "other" (data/appliance ISOs
// v0.4.67: count both protocols. Label generically since // like VMware VCSA, or as-yet-unclassified images) instead of
// operators may be using one, the other, or both. // being lumped under "Linux".
((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0)) + const LINUX = ['debian_ubuntu', 'rhel_fedora', 'opensuse', 'arch', 'alpine'];
' remote share' + const win = isos.filter(i => i.introspection.family === 'windows_pe').length;
(((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0)) === 1 ? '' : 's')), const lin = isos.filter(i => LINUX.includes(i.introspection.family)).length;
const other = isos.length - win - lin;
// v0.4.67+v0.5.5: count all remote-share protocols. Label
// generically since operators may use any mix of SMB/NFS/SFTP.
const remote = (status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0);
const parts = [win + ' Windows', lin + ' Linux'];
if (other > 0) parts.push(other + ' other');
parts.push(remote + ' remote share' + (remote === 1 ? '' : 's'));
return parts.join(' · ');
})()),
])), ])),
el('div', {class: 'card'}, el('div', {class: 'stat'}, [ el('div', {class: 'card'}, el('div', {class: 'stat'}, [
el('div', {class: 'label'}, 'Uptime'), el('div', {class: 'label'}, 'Uptime'),
@@ -340,7 +410,7 @@
const settings = status.settings; const settings = status.settings;
const problems = isos.map(i => ({i, b: bootability(i, settings)})).filter(x => !x.b.ok); const problems = isos.map(i => ({i, b: bootability(i, settings)})).filter(x => !x.b.ok);
const problemsBlock = problems.length ? el('div', {class:'card'}, [ const problemsBlock = problems.length ? el('div', {class:'card'}, [
el('header', {}, [el('h2', {}, 'Images that won\'t boot with current settings')]), el('header', {}, [el('h2', {}, 'Non-bootable images')]),
el('div', {class:'body'}, el('div', {class:'body'},
problems.map(({i, b}) => el('div', {class:'row-warn'}, problems.map(({i, b}) => el('div', {class:'row-warn'},
'⚠ ' + i.filename + ' — ' + b.reason))) '⚠ ' + i.filename + ' — ' + b.reason)))
@@ -369,6 +439,11 @@
el('div', {class:'v'}, net.server_ip || '?'), el('div', {class:'v'}, net.server_ip || '?'),
el('div', {class:'k'}, 'NIC name'), el('div', {class:'k'}, 'NIC name'),
el('div', {class:'v'}, net.nic_name || '(auto-detect failed)'), el('div', {class:'v'}, net.nic_name || '(auto-detect failed)'),
// v0.7.2: physical link details (operstate · speed · duplex ·
// port MAC) so the operator can confirm WHICH port answers
// PXE in multi-NIC / trunked environments.
el('div', {class:'k'}, 'Link'),
el('div', {class:'v'}, net.nic_link || '—'),
el('div', {class:'k'}, 'Subnet mask'), el('div', {class:'k'}, 'Subnet mask'),
el('div', {class:'v'}, net.subnet_mask || '?'), el('div', {class:'v'}, net.subnet_mask || '?'),
el('div', {class:'k'}, 'Gateway'), el('div', {class:'k'}, 'Gateway'),
@@ -508,10 +583,11 @@
// v0.4.67: NFSv3 added back as an in-process Rust client // v0.4.67: NFSv3 added back as an in-process Rust client
// (nfs3_client crate). Both protocols available side-by-side; // (nfs3_client crate). Both protocols available side-by-side;
// operators pick whichever their NAS prefers. // operators pick whichever their NAS prefers.
const [isos, settings, smbRes, nfsRes, disk, unattRes] = await Promise.all([ const [isos, settings, smbRes, nfsRes, sftpRes, disk, unattRes] = await Promise.all([
getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/isos'), getJSON('/api/settings'),
getJSON('/api/smb-shares'), getJSON('/api/smb-shares'),
getJSON('/api/nfs-shares'), getJSON('/api/nfs-shares'),
getJSON('/api/sftp-shares'),
getJSON('/api/storage/disk').catch(() => ({ getJSON('/api/storage/disk').catch(() => ({
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?', total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
})), })),
@@ -519,6 +595,7 @@
]); ]);
const shares = smbRes.shares || []; const shares = smbRes.shares || [];
const nfsShares = nfsRes.shares || []; const nfsShares = nfsRes.shares || [];
const sftpShares = sftpRes.shares || [];
const unattendedFiles = unattRes.files || []; const unattendedFiles = unattRes.files || [];
// ── Upload card ── // ── Upload card ──
@@ -531,6 +608,9 @@
style:'display:none', id:'file'}); style:'display:none', id:'file'});
const prog = el('div', {class:'progress', id:'prog'}, el('div', {class:'bar', id:'bar'})); const prog = el('div', {class:'progress', id:'prog'}, el('div', {class:'bar', id:'bar'}));
const upMsg = el('div', {class:'msg', id:'upmsg'}); const upMsg = el('div', {class:'msg', id:'upmsg'});
// v0.5.8: cancel button — shown only while an upload is in flight.
const cancelUpload = el('button', {class:'danger', type:'button',
style:'display:none;margin-top:12px', id:'cancel-upload'}, 'Cancel upload');
drop.onclick = () => file.click(); drop.onclick = () => file.click();
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('hover'); }); drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('hover'); });
@@ -573,6 +653,16 @@
}; };
let uploadId = null; let uploadId = null;
// v0.5.8: cancel + leave-page guard. The AbortController stops the
// in-flight chunk; the beforeunload listener warns the operator
// that navigating away aborts the upload (the server-side partial
// is then cleaned up by the DELETE in the catch below).
const ac = new AbortController();
let canceled = false;
const warnLeave = (e) => { e.preventDefault(); e.returnValue = ''; return ''; };
window.addEventListener('beforeunload', warnLeave);
cancelUpload.style.display = '';
cancelUpload.onclick = () => { canceled = true; ac.abort(); };
setStatus('Preparing upload for ' + f.name + ' (' + fmtBytes(f.size) + ')'); setStatus('Preparing upload for ' + f.name + ' (' + fmtBytes(f.size) + ')');
prog.classList.add('active'); prog.classList.add('active');
bar.style.width = '1%'; bar.style.width = '1%';
@@ -599,6 +689,7 @@
'x-openpxe-upload-complete': complete ? 'true' : 'false', 'x-openpxe-upload-complete': complete ? 'true' : 'false',
}, },
body: f.slice(offset, end), body: f.slice(offset, end),
signal: ac.signal,
}); });
if (!r.ok) throw new Error(await failText(r)); if (!r.ok) throw new Error(await failText(r));
const j = await r.json(); const j = await r.json();
@@ -614,8 +705,15 @@
try { await fetch('/api/uploads/' + encodeURIComponent(uploadId), {method: 'DELETE'}); } try { await fetch('/api/uploads/' + encodeURIComponent(uploadId), {method: 'DELETE'}); }
catch {} catch {}
} }
if (canceled || (err && err.name === 'AbortError')) {
setStatus('Upload canceled — partial file discarded.', '');
} else {
setStatus('Upload failed: ' + (err && err.message ? err.message : String(err)), 'err'); setStatus('Upload failed: ' + (err && err.message ? err.message : String(err)), 'err');
}
} finally { } finally {
window.removeEventListener('beforeunload', warnLeave);
cancelUpload.style.display = 'none';
cancelUpload.onclick = null;
prog.classList.remove('active'); prog.classList.remove('active');
if (!upMsg.className.includes('ok')) bar.style.width = '0'; if (!upMsg.className.includes('ok')) bar.style.width = '0';
} }
@@ -627,7 +725,11 @@
// *next* row of the table. Keeps the markup flat and avoids the // *next* row of the table. Keeps the markup flat and avoids the
// overhead of a real modal. // overhead of a real modal.
const rowsAndEditors = []; const rowsAndEditors = [];
isos.forEach(i => { // v0.5.8: list Available images alphabetically by filename
// (case-insensitive, natural numeric order) instead of newest-first.
const sortedIsos = [...isos].sort((a, b) =>
(a.filename || '').localeCompare(b.filename || '', undefined, { sensitivity: 'base', numeric: true }));
sortedIsos.forEach(i => {
const b = bootability(i, settings); const b = bootability(i, settings);
// v0.4.65: SMB userspace consumer (smbclient). // v0.4.65: SMB userspace consumer (smbclient).
// v0.4.67: NFS back as in-process Rust client (nfs3_client). // v0.4.67: NFS back as in-process Rust client (nfs3_client).
@@ -737,6 +839,12 @@
render('storage'); render('storage');
}; };
// v0.7.2: searchable haystack for the list filter — filename,
// detected family, category, and source all match.
const searchText = [
i.filename, familyLabel(i.introspection.family), i.category || '',
isSmb ? 'smb' : isNfs ? 'nfs' : 'local', i.id,
].join(' ').toLowerCase();
const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [ const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [
el('td', {}, [ el('td', {}, [
el('div', {style:'display:flex;align-items:center;gap:8px'}, [ el('div', {style:'display:flex;align-items:center;gap:8px'}, [
@@ -781,8 +889,24 @@
}}, 'Remove'), }}, 'Remove'),
]), ]),
]); ]);
tr.dataset.search = searchText;
rowsAndEditors.push(tr, editorRow); rowsAndEditors.push(tr, editorRow);
}); });
// v0.7.2: client-side filter over the image table. Rows travel in
// (row, password-editor) pairs; filtering hides both, and an open
// editor stays closed for filtered-out rows.
const isoSearch = el('input', {type:'search', placeholder:'Filter images… (name, family, category, source)',
spellcheck:'false', oninput: () => {
const q = isoSearch.value.trim().toLowerCase();
for (let k = 0; k + 1 < rowsAndEditors.length; k += 2) {
const row = rowsAndEditors[k];
const editor = rowsAndEditors[k + 1];
const show = !q || (row.dataset.search || '').includes(q);
row.style.display = show ? '' : 'none';
if (!show) editor.style.display = 'none';
}
}});
const isoTable = isos.length const isoTable = isos.length
? el('table', {}, [ ? el('table', {}, [
el('thead', {}, el('tr', {}, [ el('thead', {}, el('tr', {}, [
@@ -829,6 +953,7 @@
const protoSelect = el('select', {}, [ const protoSelect = el('select', {}, [
el('option', {value:'nfs'}, 'NFS (NFSv3)'), el('option', {value:'nfs'}, 'NFS (NFSv3)'),
el('option', {value:'smb'}, 'SMB / CIFS'), el('option', {value:'smb'}, 'SMB / CIFS'),
el('option', {value:'sftp'}, 'SFTP (SSH)'),
]); ]);
// SMB inputs. // SMB inputs.
@@ -893,18 +1018,65 @@
]), ]),
]); ]);
// SFTP inputs (v0.5.5). Pure-Rust russh client, in-process, so
// SFTP-sourced ISOs support HTTP Range like NFS. Auth is password
// OR an SSH private key (PEM, optional passphrase); the server's
// host key is pinned trust-on-first-use on the first connect.
const sftpServerIn = el('input', {type:'text', placeholder:'10.0.0.5'});
const sftpExportIn = el('input', {type:'text', placeholder:'/srv/isos'});
const sftpUserIn = el('input', {type:'text', placeholder:'root'});
const sftpPortIn = el('input', {type:'number', placeholder:'22', min:'1', max:'65535'});
const sftpAuthMode = el('select', {}, [
el('option', {value:'password'}, 'Password'),
el('option', {value:'key'}, 'SSH private key'),
]);
const sftpPassIn = el('input', {type:'password', placeholder:'••••••••'});
const sftpKeyIn = el('textarea', {rows:'4',
placeholder:'-----BEGIN OPENSSH PRIVATE KEY-----',
style:'width:100%;font-family:ui-monospace,monospace;font-size:12px;resize:vertical'});
const sftpPassphraseIn = el('input', {type:'password',
placeholder:'(only if the private key is encrypted)'});
const sftpPassBlock = el('label', {class:'field'},
[el('span', {class:'name'}, 'Password'), sftpPassIn]);
const sftpKeyBlock = el('div', {}, [
el('label', {class:'field'},
[el('span', {class:'name'}, 'SSH private key (PEM)'), sftpKeyIn]),
el('label', {class:'field', style:'margin-top:10px'},
[el('span', {class:'name'}, 'Key passphrase (optional)'), sftpPassphraseIn]),
]);
const syncSftpAuth = () => {
const key = sftpAuthMode.value === 'key';
sftpPassBlock.style.display = key ? 'none' : '';
sftpKeyBlock.style.display = key ? '' : 'none';
};
sftpAuthMode.addEventListener('change', syncSftpAuth);
syncSftpAuth();
const sftpFields = el('div', {}, [
el('div', {class:'form-row cols-2'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'SSH server'), sftpServerIn]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Export path'), sftpExportIn]),
]),
el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'Username'), sftpUserIn]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Port'), sftpPortIn]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Auth'), sftpAuthMode]),
]),
el('div', {style:'margin-top:14px'}, [sftpPassBlock, sftpKeyBlock]),
]);
// Swap the visible field block + clear any stale message. // Swap the visible field block + clear any stale message.
const syncProto = () => { const syncProto = () => {
const nfs = protoSelect.value === 'nfs'; const p = protoSelect.value;
smbFields.style.display = nfs ? 'none' : ''; smbFields.style.display = p === 'smb' ? '' : 'none';
nfsFields.style.display = nfs ? '' : 'none'; nfsFields.style.display = p === 'nfs' ? '' : 'none';
sftpFields.style.display = p === 'sftp' ? '' : 'none';
shareMsg.replaceChildren(); shareMsg.replaceChildren();
shareMsg.className = 'msg'; shareMsg.className = 'msg';
}; };
protoSelect.addEventListener('change', syncProto); protoSelect.addEventListener('change', syncProto);
// One add button; dispatches to the selected protocol's endpoint. // One add button; dispatches to the selected protocol's endpoint.
const addShare = el('button', {style:'margin-top:14px', onclick: async () => { const addShare = el('button', {onclick: async () => {
if (protoSelect.value === 'smb') { if (protoSelect.value === 'smb') {
if (!smbServer.value || !smbShare.value) { if (!smbServer.value || !smbShare.value) {
shareMsg.replaceChildren(document.createTextNode('Server and share name are required.')); shareMsg.replaceChildren(document.createTextNode('Server and share name are required.'));
@@ -924,6 +1096,40 @@
shareMsg.className = 'msg ok'; shareMsg.className = 'msg ok';
render('storage'); render('storage');
} else { await showShareError(r); } } else { await showShareError(r); }
} else if (protoSelect.value === 'sftp') {
if (!sftpServerIn.value || !sftpExportIn.value || !sftpUserIn.value) {
shareMsg.replaceChildren(document.createTextNode('Server, export, and username are required.'));
shareMsg.className = 'msg err'; return;
}
const useKey = sftpAuthMode.value === 'key';
if (useKey && !sftpKeyIn.value.trim()) {
shareMsg.replaceChildren(document.createTextNode('Paste the SSH private key, or switch Auth to Password.'));
shareMsg.className = 'msg err'; return;
}
if (!useKey && !sftpPassIn.value) {
shareMsg.replaceChildren(document.createTextNode('Password is required, or switch Auth to SSH private key.'));
shareMsg.className = 'msg err'; return;
}
shareMsg.replaceChildren(document.createTextNode('Connecting…'));
shareMsg.className = 'msg';
const body = {
server: sftpServerIn.value,
export: sftpExportIn.value,
username: sftpUserIn.value,
};
if (sftpPortIn.value) { body.port = parseInt(sftpPortIn.value, 10); }
if (useKey) {
body.private_key = sftpKeyIn.value;
if (sftpPassphraseIn.value) { body.passphrase = sftpPassphraseIn.value; }
} else {
body.password = sftpPassIn.value;
}
const r = await postJSON('/api/sftp-shares', body);
if (r.ok) {
shareMsg.replaceChildren(document.createTextNode('Connected.'));
shareMsg.className = 'msg ok';
render('storage');
} else { await showShareError(r); }
} else { } else {
if (!nfsServerIn.value || !nfsExportIn.value) { if (!nfsServerIn.value || !nfsExportIn.value) {
shareMsg.replaceChildren(document.createTextNode('Server and export are required.')); shareMsg.replaceChildren(document.createTextNode('Server and export are required.'));
@@ -962,9 +1168,36 @@
el('span'), el('span'),
])); ]));
const totalShares = shares.length + nfsShares.length; const sftpRowEls = sftpShares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
el('div', {}, [
el('div', {class:'id'}, [el('span', {class:'proto-badge'}, 'SFTP'),
document.createTextNode(m.username + '@' + m.server + ':' + m.export)]),
el('div', {class:'meta'},
'SSH · ' + (m.auth === 'key' ? 'key' : 'password') + ' · ' +
(m.reachable ? m.iso_count + ' isos' : 'not reachable')),
m.host_key_fingerprint
? el('div', {style:'margin-top:4px;opacity:.65;font-size:11px;font-family:ui-monospace,monospace;word-break:break-all'},
'host key ' + m.host_key_fingerprint)
: null,
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
]),
el('button', {class:'ghost', onclick: async () => {
const r = await postJSON('/api/sftp-shares/' + encodeURIComponent(m.id) + '/scan', {});
if (r.ok) render('storage');
}}, 'Re-scan'),
el('button', {class:'danger', onclick: async () => {
if (!confirm('Forget ' + m.server + ':' + m.export + '?')) return;
await fetch('/api/sftp-shares/' + encodeURIComponent(m.id), {method:'DELETE'});
render('storage');
}}, 'Remove'),
el('span'),
]));
const totalShares = shares.length + nfsShares.length + sftpShares.length;
const remoteRows = totalShares const remoteRows = totalShares
? [...smbRowEls, ...nfsRowEls] ? [...smbRowEls, ...nfsRowEls, ...sftpRowEls]
: [el('div', {class:'empty'}, 'No remote shares configured.')]; : [el('div', {class:'empty'}, 'No remote shares configured.')];
syncProto(); syncProto();
@@ -1011,7 +1244,10 @@
unattFile.onchange = () => { if (unattFile.files[0]) uploadUnattended(unattFile.files[0]); }; unattFile.onchange = () => { if (unattFile.files[0]) uploadUnattended(unattFile.files[0]); };
const unattRows = unattendedFiles.length const unattRows = unattendedFiles.length
? unattendedFiles.map(f => el('div', {class:'nfs-row'}, [ ? unattendedFiles.map(f => el('div', {
class:'nfs-row',
'data-search': (f.filename + ' ' + unattendedKindLabel(f.kind) + ' ' + f.id).toLowerCase(),
}, [
el('span', {class:'dot ok'}), el('span', {class:'dot ok'}),
el('div', {}, [ el('div', {}, [
el('div', {class:'id'}, [ el('div', {class:'id'}, [
@@ -1039,6 +1275,17 @@
]), ]),
el('div', {class:'body'}, [ el('div', {class:'body'}, [
unattDrop, unattFile, unattMsg, unattDrop, unattFile, unattMsg,
// v0.7.2: filter for big answer-file libraries.
unattendedFiles.length > 1 ? (() => {
const search = el('input', {type:'search', placeholder:'Filter files… (name, kind)',
spellcheck:'false', style:'margin-top:14px', oninput: () => {
const q = search.value.trim().toLowerCase();
unattRows.forEach(r => {
r.style.display = (!q || (r.dataset.search || '').includes(q)) ? '' : 'none';
});
}});
return search;
})() : null,
el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows), el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows),
el('p', {class:'msg', style:'margin-top:14px'}, el('p', {class:'msg', style:'margin-top:14px'},
'These answer files drive unattended installs. Attach one to a ' + 'These answer files drive unattended installs. Attach one to a ' +
@@ -1053,7 +1300,7 @@
diskCard, diskCard,
el('div', {class:'card'}, [ el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Upload ISO')), el('header', {}, el('h2', {}, 'Upload ISO')),
el('div', {class:'body'}, [drop, file, prog, upMsg]), el('div', {class:'body'}, [drop, file, prog, upMsg, cancelUpload]),
]), ]),
// v0.5.1: SMB + NFS unified into one "Remote shares" card with a // v0.5.1: SMB + NFS unified into one "Remote shares" card with a
// protocol dropdown. Backend endpoints are unchanged; this is a // protocol dropdown. Backend endpoints are unchanged; this is a
@@ -1071,13 +1318,13 @@
]), ]),
el('span'), el('span'),
]), ]),
el('div', {style:'margin-top:14px'}, [smbFields, nfsFields]), el('div', {style:'margin-top:14px'}, [smbFields, nfsFields, sftpFields]),
addShare, shareMsg, addShare, shareMsg,
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, remoteRows), el('div', {style:'margin-top:18px;display:grid;gap:8px'}, remoteRows),
el('p', {class:'msg', style:'margin-top:14px'}, el('p', {class:'msg', style:'margin-top:14px'},
'Remote .iso libraries are read on demand — no local cache to ' + 'Remote .iso libraries are read on demand — no local cache to ' +
'preserve disk usage. Support for NFS 3.0 and SMB. Ensure that ' + 'preserve disk usage. Support for NFS 3.0, SMB, and SFTP (SSH). ' +
'the hosts IP address is provisioned.'), 'Ensure that the hosts IP address is provisioned.'),
]), ]),
]), ]),
el('div', {class:'card'}, [ el('div', {class:'card'}, [
@@ -1085,16 +1332,18 @@
el('h2', {}, 'Available images'), el('h2', {}, 'Available images'),
el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')), el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')),
]), ]),
isos.length > 1 ? el('div', {class:'list-search'}, isoSearch) : null,
isoTable, isoTable,
]), ]),
]), unattendedAdvanced]); ]), unattendedAdvanced]);
}, },
hosts: async () => { hosts: async () => {
const [{ hosts = [] }, isos, bootLogRes, unattRes] = await Promise.all([ const [{ hosts = [] }, isos, bootLogRes, unattRes, rulesCfg] = await Promise.all([
getJSON('/api/hosts'), getJSON('/api/isos'), getJSON('/api/hosts'), getJSON('/api/isos'),
getJSON('/api/boot-log').catch(() => ({ events: [] })), getJSON('/api/boot-log').catch(() => ({ events: [] })),
getJSON('/api/unattended').catch(() => ({ files: [] })), getJSON('/api/unattended').catch(() => ({ files: [] })),
getJSON('/api/boot-rules').catch(() => ({ rules: [], webhook_url: '' })),
]); ]);
const bootEvents = bootLogRes.events || []; const bootEvents = bootLogRes.events || [];
const unattendedFiles = unattRes.files || []; const unattendedFiles = unattRes.files || [];
@@ -1109,8 +1358,22 @@
{id: '_tools_menu', title: '↳ Tools menu (built-in)'}, {id: '_tools_menu', title: '↳ Tools menu (built-in)'},
]; ];
const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff', spellcheck:'false'}); const macInput = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff or prefix aa:bb:cc', spellcheck:'false'});
const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'}); const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'});
// v0.7.2: the former separate "Boot rules" card folded into this
// form. A full MAC with no architecture saves a per-host pin
// exactly as before; a MAC *prefix* and/or an architecture saves a
// first-match-wins group rule instead. Same form, one mental model.
const archSel = el('select', {}, [
['', 'any (this exact MAC)'], ['bios', 'BIOS'], ['uefi-x64', 'UEFI x64'],
['uefi-ia32', 'UEFI IA32'], ['uefi-arm64', 'UEFI ARM64'],
].map(([v, t]) => el('option', {value: v}, t)));
// v0.7.1's boot-binary pin keeps its home here too (auto = let the
// escalation ladder learn; shim = known Secure Boot fleet).
const binSel = el('select', {}, [
['', 'auto (learn per machine)'], ['firmware', 'Firmware NIC'],
['builtin', 'iPXE drivers'], ['shim', 'Secure Boot (shim)'],
].map(([v, t]) => el('option', {value: v}, t)));
const targetSel = el('select', {}, const targetSel = el('select', {},
[el('option', {value:''}, '— choose a target —')] [el('option', {value:''}, '— choose a target —')]
.concat(reserved.map(t => el('option', {value: t.id}, t.title))) .concat(reserved.map(t => el('option', {value: t.id}, t.title)))
@@ -1123,21 +1386,40 @@
// hostname/IP templated into the served answer file. // hostname/IP templated into the served answer file.
const profileFields = buildProfileFields({}, unattendedFiles, 'form-row cols-3'); const profileFields = buildProfileFields({}, unattendedFiles, 'form-row cols-3');
const FULL_MAC = /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i;
const upsertBtn = el('button', {onclick: async () => { const upsertBtn = el('button', {onclick: async () => {
if (!macInput.value || !targetSel.value) { const mac = macInput.value.trim();
const isGroup = !!archSel.value || !!binSel.value || (mac !== '' && !FULL_MAC.test(mac));
if (!isGroup) {
// Exact-MAC pin — unchanged behavior.
if (!mac || !targetSel.value) {
msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return; msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
} }
const r = await postJSON('/api/hosts', Object.assign({ const r = await postJSON('/api/hosts', Object.assign({
mac: macInput.value, target: targetSel.value, label: labelInput.value, mac, target: targetSel.value, label: labelInput.value,
}, profileFields.read())); }, profileFields.read()));
if (r.ok) { if (r.ok) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; render('hosts'); }
msg.textContent = 'Saved.'; msg.className = 'msg ok'; else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
render('hosts'); return;
} else {
const t = await r.text();
msg.textContent = 'Save failed: ' + t; msg.className = 'msg err';
} }
}}, 'Bind MAC to target'); // Group rule (prefix and/or architecture). Per-host profile
// fields don't apply to a group — they're per-machine values.
if (!targetSel.value && !binSel.value) {
msg.textContent = 'A group rule needs a target or a boot binary.'; msg.className = 'msg err'; return;
}
const p = profileFields.read();
if (p.auto_hostname || p.auto_ip || p.unattended_file) {
msg.textContent = 'Auto-deploy fields are per-machine — clear them, or use a full MAC.'; msg.className = 'msg err'; return;
}
const cfg = await getJSON('/api/boot-rules').catch(() => ({rules: [], webhook_url: ''}));
(cfg.rules = cfg.rules || []).push({
mac_prefix: mac, arch: archSel.value, target: targetSel.value,
driver_mode: binSel.value, enabled: true, note: labelInput.value,
});
const r = await putJSON('/api/boot-rules', cfg);
if (r.ok) { msg.textContent = 'Group rule saved.'; msg.className = 'msg ok'; render('hosts'); }
else { msg.textContent = 'Save failed: ' + await r.text(); msg.className = 'msg err'; }
}}, 'Bind to target');
const rows = hosts.map(h => { const rows = hosts.map(h => {
// v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole // v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole
@@ -1194,23 +1476,38 @@
el('div', {class:'card'}, [ el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Pin MAC to boot target')), el('header', {}, el('h2', {}, 'Pin MAC to boot target')),
el('div', {class:'body'}, [ el('div', {class:'body'}, [
el('div', {class:'form-row'}, [ el('div', {class:'form-row cols-3'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'MAC address'), macInput]), el('label', {class:'field'}, [
el('span', {class:'name'}, 'MAC address or prefix'),
macInput,
el('span', {class:'hint'}, 'Full MAC pins one machine; a prefix (OUI) makes a group rule.'),
]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]), el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Architecture (optional)'),
archSel,
el('span', {class:'hint'}, 'Selecting one makes a group rule for that firmware.'),
]),
el('label', {class:'field', style:'grid-column:1 / -1'}, [ el('label', {class:'field', style:'grid-column:1 / -1'}, [
el('span', {class:'name'}, 'Target'), el('span', {class:'name'}, 'Target'),
targetSel, targetSel,
el('span', {class:'hint'}, el('span', {class:'hint'},
'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'), 'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'),
]), ]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Boot binary (optional)'),
binSel,
el('span', {class:'hint'}, 'Pin Secure Boot racks to "shim" — zero failed boot cycles.'),
]),
]), ]),
el('div', {style:'margin-top:16px'}, profileFields.wrap), el('div', {style:'margin-top:16px'}, profileFields.wrap),
upsertBtn, msg, upsertBtn, msg,
el('p', {class:'msg', style:'margin-top:14px'}, el('p', {class:'msg', style:'margin-top:14px'},
'When a client with a bound MAC requests boot.ipxe, OpenPXE ' + 'When a matching client requests boot.ipxe, OpenPXE short-circuits ' +
'short-circuits past the interactive menu and chains directly. ' + 'past the interactive menu and chains directly. Decision order: ' +
'If an unattended file is selected, the matching kernel argument ' + 'exact MAC pin → first matching group rule → menu. ' +
'is injected and the hostname/IP are templated into the answer file.'), 'If an unattended file is selected on a pin, the matching kernel ' +
'argument is injected and the hostname/IP are templated into the answer file.'),
]), ]),
]), ]),
el('div', {class:'card'}, [ el('div', {class:'card'}, [
@@ -1220,6 +1517,7 @@
]), ]),
table, table,
]), ]),
groupRulesCard(rulesCfg, reserved.concat(targets)),
el('div', {class:'card'}, [ el('div', {class:'card'}, [
el('header', {}, [ el('header', {}, [
el('h2', {}, 'Host log'), el('h2', {}, 'Host log'),
@@ -1411,7 +1709,7 @@
// beneath the input row instead of butting against the password // beneath the input row instead of butting against the password
// fields. Mirrors the `Save SSO settings` button below for visual // fields. Mirrors the `Save SSO settings` button below for visual
// parity between the two settings cards. // parity between the two settings cards.
const accountSave = el('button', {style:'margin-top:6px', onclick: async () => { const accountSave = el('button', {onclick: async () => {
accountMsg.textContent = ''; accountMsg.className = 'msg'; accountMsg.textContent = ''; accountMsg.className = 'msg';
if (!currentPw.value) { if (!currentPw.value) {
accountMsg.textContent = 'Current password is required.'; accountMsg.textContent = 'Current password is required.';
@@ -1549,7 +1847,7 @@
}; };
ssoMode.onchange = refreshSsoFields; ssoMode.onchange = refreshSsoFields;
const ssoMsg = el('div', {class:'msg', style:'margin-top:8px'}); const ssoMsg = el('div', {class:'msg', style:'margin-top:8px'});
const ssoSave = el('button', {style:'margin-top:16px', onclick: async () => { const ssoSave = el('button', {onclick: async () => {
ssoMsg.textContent = ''; ssoMsg.className = 'msg'; ssoMsg.textContent = ''; ssoMsg.className = 'msg';
const payload = { const payload = {
enabled: ssoEnabled.checked, enabled: ssoEnabled.checked,
@@ -1630,13 +1928,17 @@
const logoMsg = el('div', {class:'msg', style:'margin-top:10px'}); const logoMsg = el('div', {class:'msg', style:'margin-top:10px'});
const bust = '?v=' + Date.now(); // bust the preview cache after a change const bust = '?v=' + Date.now(); // bust the preview cache after a change
const brandingPresence = status.branding || { light:false, dark:false, client:false }; const brandingPresence = status.branding || { light:false, dark:false, client:false };
// Each swatch previews on a background matching where the mark
// lands (light page / dark page / dark PXE screen), independent of
// the operator's current page theme — so the Dark slot always reads
// as dark even while viewing Settings in light mode.
const slotDefs = [ const slotDefs = [
{ slot:'light', title:'Light mode', preview:'/assets/logo.svg?theme=light' + '&' + bust.slice(1), { slot:'light', title:'Light mode', preview:'/assets/logo.svg?theme=light' + '&' + bust.slice(1),
hint:'Shown on light-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' }, swatchBg:'#f4f5f7', hint:'Shown on light-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' },
{ slot:'dark', title:'Dark mode', preview:'/assets/logo.svg?theme=dark' + '&' + bust.slice(1), { slot:'dark', title:'Dark mode', preview:'/assets/logo.svg?theme=dark' + '&' + bust.slice(1),
hint:'Shown on dark-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' }, swatchBg:'#0e1014', hint:'Shown on dark-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' },
{ slot:'client', title:'Client', preview:'/branding/pxe-logo' + bust, { slot:'client', title:'Client', preview:'/branding/pxe-logo' + bust,
hint:'Above the PXE boot menu.', accept:'image/png,image/jpeg,image/webp,image/gif' }, swatchBg:'#0e1014', hint:'Above the PXE boot menu.', accept:'image/png,image/jpeg,image/webp,image/gif' },
]; ];
const slotCol = (def) => { const slotCol = (def) => {
const set = !!brandingPresence[def.slot]; const set = !!brandingPresence[def.slot];
@@ -1662,7 +1964,7 @@
el('span', {class:'name'}, def.title), el('span', {class:'name'}, def.title),
set ? el('span', {class:'tag ok'}, 'set') : el('span', {class:'tag'}, 'default'), set ? el('span', {class:'tag ok'}, 'set') : el('span', {class:'tag'}, 'default'),
]), ]),
el('div', {class:'swatch', style: def.slot === 'light' ? 'background:#f4f5f7' : ''}, el('div', {class:'swatch', style:'background:' + def.swatchBg},
el('img', {src: def.preview, alt: def.title + ' logo'})), el('img', {src: def.preview, alt: def.title + ' logo'})),
el('div', {class:'logo-slot-hint'}, def.hint), el('div', {class:'logo-slot-hint'}, def.hint),
el('div', {style:'display:flex;gap:6px;flex-wrap:wrap'}, [ el('div', {style:'display:flex;gap:6px;flex-wrap:wrap'}, [
@@ -1795,13 +2097,13 @@
smtp_implicit_tls: sTls.checked, smtp_implicit_tls: sTls.checked,
}); });
const saveBtn = el('button', {style:'margin-top:16px', onclick: async () => { const saveBtn = el('button', {onclick: async () => {
nMsg.textContent = 'Saving…'; nMsg.className = 'msg'; nMsg.textContent = 'Saving…'; nMsg.className = 'msg';
const r = await putJSON('/api/notify', collectNotify()); const r = await putJSON('/api/notify', collectNotify());
if (r.ok) { nMsg.textContent = 'Saved.'; nMsg.className = 'msg ok'; render('settings'); } if (r.ok) { nMsg.textContent = 'Saved.'; nMsg.className = 'msg ok'; render('settings'); }
else { nMsg.textContent = 'Save failed: ' + (await r.text()); nMsg.className = 'msg err'; } else { nMsg.textContent = 'Save failed: ' + (await r.text()); nMsg.className = 'msg err'; }
}}, 'Save notification settings'); }}, 'Save notification settings');
const testBtn = el('button', {class:'ghost', style:'margin-top:16px;margin-left:8px', const testBtn = el('button', {class:'ghost', style:'margin-left:8px',
onclick: async () => { onclick: async () => {
nMsg.textContent = 'Sending test…'; nMsg.className = 'msg'; nMsg.textContent = 'Sending test…'; nMsg.className = 'msg';
// Save first so the test uses exactly what's on screen. // Save first so the test uses exactly what's on screen.
@@ -1892,9 +2194,24 @@
el('div', {class:'about-hero'}, [ el('div', {class:'about-hero'}, [
el('h2', {}, 'OpenPXE'), el('h2', {}, 'OpenPXE'),
el('p', {class:'lead'}, el('p', {class:'lead'},
'Air-gapped network PXE boot, container-native, that anyone can run. ' + 'The network-boot platform for modern infrastructure. Drop in an ISO ' +
'No CDN calls, no telemetry, no surprise external dependencies — ship ' + 'and every machine on your network — BIOS, UEFI, Secure Boot — can ' +
'the image once, run it forever.'), 'boot it, image from it, and install unattended. One container, one ' +
'static binary, nothing installed on clients, nothing leaving your network.'),
el('div', {style:'display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:18px 0'}, [
['Boot anything', 'Linux, Windows, hypervisors, rescue tools — uploaded ' +
'ISOs become menu entries automatically, served on demand from local ' +
'disk or your existing NFS, SMB, or SFTP libraries.'],
['Adapt to every machine', 'Per-machine boot intelligence: firmware quirks, ' +
'NIC driver fallback, and a Microsoft-signed Secure Boot chain are ' +
'negotiated automatically and remembered — no toggles, no client prep.'],
['Run it in production', 'SAML single sign-on, token-scoped answer files, ' +
'fleet routing rules, Wake-on-LAN, queued mass deployment, Prometheus ' +
'metrics. Built in Rust for boot infrastructure that cannot flinch.'],
].map(([h, body]) => el('div', {}, [
el('h3', {style:'margin:0 0 6px;font-size:13.5px'}, h),
el('p', {class:'msg', style:'font-size:12px;margin:0'}, body),
]))),
el('div', {class:'who'}, [ el('div', {class:'who'}, [
el('span', {}, 'Developer: '), el('strong', {}, 'Miles Ward'), el('br'), el('span', {}, 'Developer: '), el('strong', {}, 'Miles Ward'), el('br'),
el('span', {}, 'Version: '), el('strong', {}, status.version || '?'), el('br'), el('span', {}, 'Version: '), el('strong', {}, status.version || '?'), el('br'),
@@ -1905,15 +2222,16 @@
]), ]),
el('div', {style:'margin-top:18px'}, [updBtn, updMsg]), el('div', {style:'margin-top:18px'}, [updBtn, updMsg]),
el('p', {class:'msg', style:'margin-top:18px'}, el('p', {class:'msg', style:'margin-top:18px'},
'iPXE is an internal implementation detail. Everything the firmware ' + 'Private by design: no telemetry, no CDN calls, no runtime ' +
'executes is generated from the settings on these tabs — there is no ' + 'dependencies on the outside world. Air-gapped labs, customer sites ' +
'hand-written .ipxe path anywhere in this product.'), 'without internet, and locked-down OpenShift clusters run the same ' +
'image, the same way, indefinitely.'),
el('p', {class:'msg'}, el('p', {class:'msg'},
'Vision: a deployment-grade tool that works on first try in the most ' + 'Principled by default: OpenPXE never asks an operator to install ' +
'awkward environments — air-gapped labs, customer sites without ' + 'test-signed drivers, modify a clients trust store, or weaken ' +
'internet, OpenShift clusters with strict SCCs — without ever asking ' + 'Secure Boot. Everything the firmware executes is generated from the ' +
'an operator to install drivers signed with test certificates or to ' + 'settings on these tabs — there are no hand-written boot scripts to ' +
'flip "testsigning" on a target machine.'), 'maintain and no internals to learn.'),
]), ]),
]); ]);
@@ -2137,7 +2455,9 @@
// own self-contained <form>; when SSO is enabled, a distinct // own self-contained <form>; when SSO is enabled, a distinct
// "Sign in with …" button sits below a divider — the credential // "Sign in with …" button sits below a divider — the credential
// fields no longer double as the SSO trigger. // fields no longer double as the SSO trigger.
const ssoLive = ssoConfig && ssoConfig.enabled && (ssoConfig.metadata_url || ssoConfig.metadata); // `enabled` from /api/me already means "usable" (enabled AND a metadata
// source is configured), so the button only shows when SSO will work.
const ssoLive = !!(ssoConfig && ssoConfig.enabled);
const ssoBlock = ssoLive const ssoBlock = ssoLive
? el('div', {class:'sso-block'}, [ ? el('div', {class:'sso-block'}, [
el('div', {class:'auth-divider'}, el('span', {}, 'or')), el('div', {class:'auth-divider'}, el('span', {}, 'or')),
@@ -2382,10 +2702,12 @@
]))); ])));
return; return;
} }
// Preload the SSO config so the login card can offer the operator // v0.5.9: the login card's "Sign in with …" button keys off the SSO
// an "Sign in with X" button when configured. Failure is harmless. // descriptor that /api/me now carries (public, non-sensitive: enabled
try { ssoConfig = await fetch('/api/sso').then(r => r.ok ? r.json() : null); } // + idp_name + idp_logo_url). It's available signed in or out, so the
catch { ssoConfig = null; } // button is static — it no longer relied on the auth-gated /api/sso,
// which 401s pre-auth and made the button vanish on fresh login loads.
ssoConfig = me.sso || null;
if (me.setup_required) { if (me.setup_required) {
showAuthScreen('setup'); showAuthScreen('setup');
+39 -27
View File
@@ -23,11 +23,19 @@ ARG RUST_VERSION=1.95
# and serve as the baseline that the PNG-enabled x86_64/arm64 UEFI # and serve as the baseline that the PNG-enabled x86_64/arm64 UEFI
# binaries from the `ipxe-build` stage overlay on top of. # binaries from the `ipxe-build` stage overlay on top of.
FROM debian:12-slim AS fetch FROM debian:12-slim AS fetch
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ # rpm2cpio + cpio: extract Fedora's Microsoft-signed shim/GRUB RPMs for
# the Secure Boot chain (v0.7.0, scripts/fetch-shim.sh).
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates rpm2cpio cpio \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /src WORKDIR /src
COPY scripts/fetch-ipxe.sh scripts/fetch-ipxe.sh COPY scripts/fetch-ipxe.sh scripts/fetch-ipxe.sh
COPY scripts/fetch-shim.sh scripts/fetch-shim.sh
RUN mkdir -p assets/ipxe && bash scripts/fetch-ipxe.sh RUN mkdir -p assets/ipxe && bash scripts/fetch-ipxe.sh
# Signed shim+GRUB (Secure Boot escalation rung). Redistributed
# unmodified from the official Fedora packages — see fetch-shim.sh for
# the trust model.
RUN bash scripts/fetch-shim.sh /src/assets/ipxe
########## build PNG-enabled iPXE from source ########## ########## build PNG-enabled iPXE from source ##########
# v0.4.69: THE graphical-boot-menu unlock. iVentoy paints a PNG # v0.4.69: THE graphical-boot-menu unlock. iVentoy paints a PNG
@@ -60,35 +68,41 @@ COPY deploy/ipxe/local/ deploy/ipxe/local/
RUN mkdir -p assets/ipxe && bash scripts/build-ipxe.sh /src/assets/ipxe RUN mkdir -p assets/ipxe && bash scripts/build-ipxe.sh /src/assets/ipxe
########## build openpxe ########## ########## build openpxe ##########
FROM rust:${RUST_VERSION}-bookworm AS build # v0.5.2: cross-compile the Rust binary NATIVELY — no QEMU.
WORKDIR /src
# v0.4.5: build a fully static musl binary (matches Bootimus v0.1.70's
# move). The resulting `/openpxe` has no glibc dependency at all, which:
# - Lets the runtime stage be any Linux distro (we still ship Debian
# slim for the `samba` / `wimtools` / `nfs-common` shellouts, but a
# scratch/distroless variant becomes a one-line swap).
# - Cuts a class of "GLIBC_2.39 not found" surprises when running on
# older RHEL/Rocky hosts that don't match Debian 12's libc version.
# - Sidesteps cross-compilation snags (the binary is its own world).
# #
# x86_64-unknown-linux-musl is fully static by default (no extra # This stage is pinned to $BUILDPLATFORM (the builder's native arch — arm64
# RUSTFLAGS needed). musl-tools provides the linker. # on an Apple-Silicon Mac, amd64 in x86 CI), exactly like `ipxe-build`. The
# Rust compiler therefore runs at full native speed and emits an
# x86_64-unknown-linux-musl binary via `cargo-zigbuild`, which uses `zig cc`
# as the cross-linker (it bundles the musl sysroot for every target, so
# there's no fiddly cross-gcc toolchain to assemble).
#
# Why this replaced the old `FROM rust ... --platform=linux/amd64` build:
# that ran the *entire* compiler under QEMU x86_64 emulation on the arm64
# host. It was ~15x slower (a single crate took >20 min) and the emulated
# gcc/linker intermittently SIGSEGV'd or hung mid-link. Cross-compiling
# sidesteps emulation entirely — the build is minutes, not half an hour,
# and is deterministic.
#
# The output is still a fully static musl binary with no glibc dependency,
# so the runtime stage stays free to be any Linux distro.
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-bookworm AS build
WORKDIR /src
# zig (via the `ziglang` pip package — cargo-zigbuild auto-discovers it as
# `python3 -m ziglang`) supplies the x86_64 musl sysroot + linker.
# cargo-zigbuild is the thin cargo wrapper that wires zig in as the linker.
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends musl-tools \ && apt-get install -y --no-install-recommends python3 python3-pip \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& rustup target add x86_64-unknown-linux-musl && rustup target add x86_64-unknown-linux-musl \
&& pip3 install --no-cache-dir --break-system-packages ziglang \
&& cargo install --locked cargo-zigbuild
# Copy the whole workspace in one go. We used to do a two-pass "cache-prime
# with stubs, then real build" dance for dep-compile reuse; that turned out
# to silently serve stale stub binaries when cargo's fingerprint didn't
# notice the source swap. A single build is ~1.5 min longer on cold cache
# but guarantees the binary reflects the sources we copied.
# Do not copy rust-toolchain.toml into the image. The local workspace pins # Do not copy rust-toolchain.toml into the image. The local workspace pins
# developer tooling, but inside Docker we intentionally use the Rust version # developer tooling, but inside Docker we intentionally use the Rust version
# selected by the base image. Copying rust-toolchain.toml with # selected by the base image. Copying rust-toolchain.toml with
# `channel = "stable"` makes rustup download a second full toolchain during # `channel = "stable"` makes rustup download a second full toolchain during
# `cargo build`, which is slow and can exhaust small Colima/CI disks. # the build, which is slow and can exhaust small Colima/CI disks.
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/ COPY crates/ crates/
# Baseline binaries (BIOS / i386 / wimboot), then overlay the # Baseline binaries (BIOS / i386 / wimboot), then overlay the
@@ -100,13 +114,11 @@ COPY --from=fetch /src/assets/ipxe /src/assets/ipxe
COPY --from=ipxe-build /src/assets/ipxe/snponly.efi /src/assets/ipxe/snponly.efi COPY --from=ipxe-build /src/assets/ipxe/snponly.efi /src/assets/ipxe/snponly.efi
COPY --from=ipxe-build /src/assets/ipxe/ipxe.efi /src/assets/ipxe/ipxe.efi COPY --from=ipxe-build /src/assets/ipxe/ipxe.efi /src/assets/ipxe/ipxe.efi
# Cache cargo registry + target across builds. The mtime touch is # Cache cargo registry + target across builds. `cargo zigbuild` runs the
# belt-and-suspenders: cargo occasionally misses mtime-only changes on # native rustc (fast) and links for x86_64-musl with zig — no emulation.
# networked FS; this forces a fingerprint check.
RUN --mount=type=cache,target=/usr/local/cargo/registry \ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/src/target,sharing=locked \ --mount=type=cache,target=/src/target,sharing=locked \
find crates -name '*.rs' -exec touch {} + && \ cargo zigbuild --release --target x86_64-unknown-linux-musl --bin openpxe && \
cargo build --release --target x86_64-unknown-linux-musl --bin openpxe && \
cp target/x86_64-unknown-linux-musl/release/openpxe /openpxe && \ cp target/x86_64-unknown-linux-musl/release/openpxe /openpxe && \
ls -l /openpxe ls -l /openpxe
+21
View File
@@ -0,0 +1,21 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OpenPXE">
<title>OpenPXE</title>
<!-- Static README mark: the "rainbow-horizon" medallion from the web UI,
with the SMIL animation removed so it renders reliably as an <img>
on Gitea/GitHub. -->
<defs>
<linearGradient id="opxRainbow" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#330f1f"/>
<stop offset="12.56%" stop-color="#c83228"/>
<stop offset="25.06%" stop-color="#fb8841"/>
<stop offset="37.56%" stop-color="#d3dd92"/>
<stop offset="50.06%" stop-color="#59824f"/>
<stop offset="62.06%" stop-color="#002414"/>
<stop offset="74.06%" stop-color="#00143d"/>
<stop offset="86.06%" stop-color="#2874d7"/>
<stop offset="100%" stop-color="#99c2ff"/>
</linearGradient>
</defs>
<circle cx="12" cy="12" r="10.5" fill="url(#opxRainbow)"
stroke="rgba(0,0,0,0.18)" stroke-width="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 984 B

+23 -7
View File
@@ -41,15 +41,31 @@ DEST="${1:-$ROOT/assets/ipxe}"
WORK="$(mktemp -d)" WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT trap 'rm -rf "$WORK"' EXIT
# Pinned upstream iPXE. Rolling master is fine functionally, but a pin # Pinned upstream iPXE. Rolling master is fine functionally, but a pin keeps
# keeps builds reproducible and protects against a transient master # builds reproducible, protects against a transient master breakage, and —
# breakage. Bump deliberately. # crucially for the Docker image — busting this value invalidates the cached
# ipxe-build layer so an "update iPXE" release actually recompiles from the
# new upstream. Bump deliberately to a recent master commit.
#
# v0.6.1: ipxe/ipxe master @ 2026-06-09 (newer NIC drivers + EFI fixes;
# mirrors iVentoy 1.0.35 "Update iPXE").
IPXE_REPO="https://github.com/ipxe/ipxe.git" IPXE_REPO="https://github.com/ipxe/ipxe.git"
IPXE_REF="${IPXE_REF:-master}" IPXE_REF="${IPXE_REF:-95ffbf4745553e8a207922389929e1943c0237c0}"
echo ">> cloning iPXE ($IPXE_REF)" echo ">> fetching iPXE ($IPXE_REF)"
git clone --depth 1 --branch "$IPXE_REF" "$IPXE_REPO" "$WORK/ipxe" 2>/dev/null \ # Shallow-fetch the exact ref: works for a full commit SHA (GitHub allows
|| git clone "$IPXE_REPO" "$WORK/ipxe" # reachable-SHA1-in-want) and for branch/tag names. Fall back to a full
# clone + checkout if the server refuses a direct fetch of this ref.
git init -q "$WORK/ipxe"
git -C "$WORK/ipxe" remote add origin "$IPXE_REPO"
if git -C "$WORK/ipxe" fetch -q --depth 1 origin "$IPXE_REF"; then
git -C "$WORK/ipxe" checkout -q FETCH_HEAD
else
echo " direct fetch failed; falling back to full clone + checkout"
rm -rf "$WORK/ipxe"
git clone -q "$IPXE_REPO" "$WORK/ipxe"
git -C "$WORK/ipxe" checkout -q "$IPXE_REF"
fi
SRC="$WORK/ipxe/src" SRC="$WORK/ipxe/src"
echo ">> applying OpenPXE config overrides (PNG + framebuffer + console cmd)" echo ">> applying OpenPXE config overrides (PNG + framebuffer + console cmd)"
+9 -1
View File
@@ -29,11 +29,19 @@ mkdir -p "$DEST"
# Upstream uses arch-scoped subdirectories; we flatten to the names our # Upstream uses arch-scoped subdirectories; we flatten to the names our
# ClientArch::ipxe_bootfile() expects. # ClientArch::ipxe_bootfile() expects.
declare -a MAP=( declare -a MAP=(
# DriverMode::Firmware (default) — reuse the firmware UNDI/SNP NIC stack.
"undionly.kpxe=undionly.kpxe" "undionly.kpxe=undionly.kpxe"
"snponly.efi=x86_64-efi/snponly.efi" "snponly.efi=x86_64-efi/snponly.efi"
"snponly-i386.efi=i386-efi/snponly.efi" "snponly-i386.efi=i386-efi/snponly.efi"
"snponly-arm64.efi=arm64-efi/snponly.efi" "snponly-arm64.efi=arm64-efi/snponly.efi"
"ipxe.efi=x86_64-efi/ipxe.efi" # fallback with bundled drivers # DriverMode::Builtin (v0.6.1 automatic fallback) — iPXE's own all-drivers
# builds, advertised by the DHCP proxy to a MAC whose firmware NIC stack
# failed to chainload. (x86_64 ipxe.efi is rebuilt from source with PNG in
# build-ipxe.sh and overlaid on top of this fetched baseline.)
"ipxe.efi=x86_64-efi/ipxe.efi"
"ipxe.pxe=ipxe.pxe"
"ipxe-i386.efi=i386-efi/ipxe.efi"
"ipxe-arm64.efi=arm64-efi/ipxe.efi"
) )
BASE="https://boot.ipxe.org" BASE="https://boot.ipxe.org"
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Fetch Fedora's Microsoft-signed Secure Boot chain — shim + GRUB — and
# place the EFI binaries under assets/ipxe/ with the filenames OpenPXE's
# DriverMode::Shim mapping expects:
#
# shimx64.efi x86_64: Microsoft-signed shim (first stage)
# grubx64.efi x86_64: Fedora-signed GRUB (loaded by shim, fetches
# the server-rendered grub.cfg over TFTP/HTTP)
# shimaa64.efi arm64 equivalents (best-effort — see below)
# grubaa64.efi
#
# Why Fedora: a supply-chain decision made deliberately (v0.7.0) — one
# vendor, fast security turnaround, and the same chain most netboot
# projects redistribute. The binaries are extracted from the official
# distro RPMs and shipped BYTE-FOR-BYTE UNMODIFIED; their signatures are
# what make the chain work, and modifying them would break it. This is
# the standard documented netboot path for Secure Boot (Red Hat
# Satellite, SUSE HTTPBoot) and involves no test certificates and no
# client trust-store changes.
#
# Trust model matches fetch-ipxe.sh: HTTPS to the official distribution
# point, no sha pinning because we track the latest signed build (which
# rotates on SBAT revocations — pinning would mean shipping revoked
# shims). Mirror to your own artifact store for deterministic builds.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DEST="${1:-$ROOT/assets/ipxe}"
mkdir -p "$DEST"
FEDORA_RELEASE="${FEDORA_RELEASE:-43}"
BASE="${FEDORA_MIRROR:-https://dl.fedoraproject.org/pub/fedora/linux/releases/$FEDORA_RELEASE/Everything}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
# Find the newest RPM in a repo directory whose name starts with
# `$pattern` followed by a version digit (anchoring on the digit keeps
# `grub2-efi-x64` from matching `grub2-efi-x64-cdboot`).
latest_rpm() {
local dir_url="$1" pattern="$2"
curl -fsSL "$dir_url/" \
| grep -oE "href=\"${pattern}-[0-9][^\"]*\.rpm\"" \
| sed 's/^href="//; s/"$//' \
| sort -V | tail -1
}
# fetch_chain <repo-arch> <shim-pkg> <grub-pkg> <shim-out> <grub-out> <hard|soft>
fetch_chain() {
local arch="$1" shim_pkg="$2" grub_pkg="$3" shim_out="$4" grub_out="$5" mode="$6"
local pkg_base="$BASE/$arch/os/Packages"
local sdir="$pkg_base/${shim_pkg:0:1}" gdir="$pkg_base/${grub_pkg:0:1}"
local shim_rpm grub_rpm
shim_rpm="$(latest_rpm "$sdir" "$shim_pkg" || true)"
grub_rpm="$(latest_rpm "$gdir" "$grub_pkg" || true)"
if [ -z "$shim_rpm" ] || [ -z "$grub_rpm" ]; then
echo "!! could not locate $shim_pkg/$grub_pkg RPMs under $pkg_base"
[ "$mode" = "hard" ] && exit 2
echo " skipping $arch Secure Boot chain (best-effort)"
return 0
fi
echo ">> $arch: $shim_rpm + $grub_rpm"
local exdir="$WORK/$arch"
mkdir -p "$exdir"
curl -fsSL -o "$exdir/shim.rpm" "$sdir/$shim_rpm"
curl -fsSL -o "$exdir/grub.rpm" "$gdir/$grub_rpm"
( cd "$exdir" \
&& rpm2cpio shim.rpm | cpio -idm --quiet "./boot/efi/EFI/*/$shim_out" \
&& rpm2cpio grub.rpm | cpio -idm --quiet "./boot/efi/EFI/*/$grub_out" )
local shim_path grub_path
shim_path="$(find "$exdir/boot" -name "$shim_out" | head -1)"
grub_path="$(find "$exdir/boot" -name "$grub_out" | head -1)"
if [ -z "$shim_path" ] || [ -z "$grub_path" ]; then
echo "!! RPM layout changed — $shim_out/$grub_out not found inside the packages"
[ "$mode" = "hard" ] && exit 2
return 0
fi
cp "$shim_path" "$DEST/$shim_out"
cp "$grub_path" "$DEST/$grub_out"
echo " installed $shim_out + $grub_out"
}
# x86_64 is the headline Secure Boot audience — fail the build if it
# can't be assembled so a regression is loud, not silent.
fetch_chain x86_64 shim-x64 grub2-efi-x64 shimx64.efi grubx64.efi hard
# arm64 is best-effort: skipping just means no Shim escalation rung for
# that arch (logged at startup by ipxe-assets::log_availability).
fetch_chain aarch64 shim-aa64 grub2-efi-aa64 shimaa64.efi grubaa64.efi soft
echo
echo "Secure Boot chain assets now in $DEST:"
ls -lh "$DEST"/shim*.efi "$DEST"/grub*.efi 2>/dev/null || true