Compare commits

...
22 Commits
Author SHA1 Message Date
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
Miles WardandClaude Opus 4.8 7adf5e2918 v0.5.2: FleetDM login split, 3-slot branding, unattended installs
Authentication / login:
- Separate the local username/password form from the SSO "Sign in with …"
  button (FleetDM-style divider + optional IdP logo); credential fields no
  longer double as the SSO trigger. Settings → SSO copy now says SAML is live.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 2. NFS AUTH_SYS credential — fixes NFS3ERR_ACCES

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

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

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

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

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

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

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

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

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

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

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

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

## 3. Disk-space card on the Dashboard

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

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

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

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

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

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

## What's new

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

## What's the same

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

## Why nfs3_client

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

## Tests

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

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

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

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

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

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

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

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

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

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

What's gone:

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

What's new:

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

Why subprocess and not a Rust crate:

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

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

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

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

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

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

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

What's new:

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-26 02:32:38 -04:00
54 changed files with 14958 additions and 1891 deletions
Generated
+2867 -55
View File
File diff suppressed because it is too large Load Diff
+63 -6
View File
@@ -12,7 +12,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.4.62" version = "0.5.8"
edition = "2021" edition = "2021"
rust-version = "1.95" rust-version = "1.95"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
@@ -35,29 +35,86 @@ axum = { version = "0.7", features = ["macros", "multipart", "http2"] }
tower = "0.5" tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] } tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] }
hyper = "1.4" hyper = "1.4"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } 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 = "0.10" sha2 = "0.10"
hex = "0.4" hex = "0.4"
bcrypt = "0.15" bcrypt = "0.15"
once_cell = "1.19"
parking_lot = "0.12" parking_lot = "0.12"
rust-embed = { version = "8.5", features = ["include-exclude"] } rust-embed = { version = "8.5", features = ["include-exclude"] }
# v0.4.67: pure-Rust NFSv3 client. Replaces the (deleted-in-v0.4.65)
# kernel-mount NFS path with an in-process implementation that works
# in any container — no kernel modules, no CAP_SYS_ADMIN, no
# subprocess. Ships alongside the userspace SMB consumer; operators
# pick whichever protocol their NAS prefers.
nfs3_client = { version = "0.9", features = ["tokio"] }
nfs3_types = "0.5"
# v0.5.0: SMTP for webhook notifications (Slack/Teams/Discord go over
# plain HTTP via reqwest; email needs a real SMTP client). rustls TLS
# to match reqwest and stay musl-static-friendly — no OpenSSL.
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "builder", "hostname"] }
# v0.5.1: pure-Rust SAML 2.0 Service Provider. bergshamra does XML-DSig
# verification + exclusive c14n with RustCrypto (no OpenSSL/xmlsec/libxml2
# C deps), so the static musl binary stays OpenSSL-free — samael was
# rejected precisely because it hard-requires OpenSSL. We build the thin
# SP layer (AuthnRequest, metadata parse, SAMLResponse semantics) on top.
bergshamra = "0.4"
roxmltree = "0.21"
quick-xml = "0.40"
x509-parser = "0.18"
# flate2 default backend is miniz_oxide (pure Rust) — do NOT enable the
# zlib/zlib-ng C backends, which would break the musl-static build.
flate2 = "1.1"
base64 = "0.22"
# v0.5.5: pure-Rust SSH/SFTP client for reading remote ISO libraries
# over SFTP without a kernel mount.
#
# CRITICAL #1 — crypto backend: `default-features = false` +
# `features = ["ring"]`. russh's *default* backend is `aws-lc-rs`, which
# pulls `aws-lc-sys` (C code, fiddly under musl); the `ring` feature
# instead reuses `ring 0.17` — the exact crate+version already in the
# binary via rustls + bergshamra — so SFTP adds ZERO new C/crypto deps
# and the static-musl build stays OpenSSL-free.
#
# CRITICAL #2 — pinned to EXACTLY 0.55.0, the newest russh that
# coexists with bergshamra-crypto (our SAML core). The RustCrypto
# ecosystem is mid-transition: bergshamra-crypto pins a constellation of
# release-CANDIDATE crates (`pkcs8 =0.11.0-rc.11` and its matching
# pkcs5/spki RCs) that are API-incompatible with the STABLE versions of
# the same crates in the same semver bucket. russh 0.56+ pulls those
# stable crates (`pkcs5 0.8`), which silently replaces bergshamra's RC
# copies and breaks compilation. russh ≤0.55 stays on the previous stable
# generation (`pkcs5 0.7`, `ssh-key 0.6`), which unifies with bergshamra's
# *stable* deps and leaves the RC bucket untouched — verified to compile.
# 0.55 still has the merged `russh::keys` API (keys merged at 0.50).
# IMPORTANT: do NOT bump russh past 0.55 until bergshamra-crypto adopts
# the stable RustCrypto generation; 0.56+ will not compile in this tree.
#
# SCP was deliberately rejected: the protocol is sequential-only (no
# random access → no HTTP Range, unlike SFTP/NFS) and the mature SCP
# crates wrap libssh2 (C + OpenSSL), which would break this build.
russh = { version = "=0.55.0", 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.5.5-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.5.5, 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.
+18
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
@@ -25,5 +26,22 @@ tokio = { workspace = true, features = ["sync", "rt", "macros", "time"] }
# for per-ISO boot passwords; just re-exported here. # for per-ISO boot passwords; just re-exported here.
bcrypt.workspace = true bcrypt.workspace = true
# v0.5.1: pure-Rust SAML 2.0 SP. bergshamra = XML-DSig verify + exclusive
# c14n (no OpenSSL/C). roxmltree/quick-xml parse + build SAML XML;
# x509-parser pulls the IdP signing cert out of metadata; flate2+base64
# encode the HTTP-Redirect binding's SAMLRequest.
bergshamra.workspace = true
roxmltree.workspace = true
quick-xml.workspace = true
x509-parser.workspace = true
flate2.workspace = true
base64.workspace = true
[dev-dependencies] [dev-dependencies]
tempfile = "3.12" tempfile = "3.12"
# v0.5.4: figment's `Jail` (hermetic env/file sandbox) for the config
# loader tests lives behind the `test` feature.
figment = { workspace = true, features = ["test"] }
# v0.5.1: generate a throwaway self-signed signing cert/key so SAML
# verification tests can produce genuinely signed SAMLResponses.
rcgen = "0.13"
+457 -131
View File
@@ -1,11 +1,23 @@
//! Operator-controlled branding overrides. //! Operator-controlled branding overrides.
//! //!
//! The browser tab's logo (`/assets/logo.svg`) defaults to the bundled //! v0.5.2 splits the single brand mark into **three independent slots**,
//! rainbow-horizon mark. Operators who deploy OpenPXE behind their own //! FleetDM-style:
//! branding can upload a replacement that lives at //!
//! `<work_dir>/branding/logo.<ext>` and is served in preference to the //! * `light` — shown in the WebUI top-left and on the form-login page
//! bundled SVG when present. Borrowed-from-FleetDM: tenant chrome, same //! when the active theme is light.
//! product. //! * `dark` — same surfaces, when the active theme is dark.
//! * `client` — the raster painted above the iPXE boot menu entries
//! (`/branding/pxe-logo`), i.e. what a PXE client sees on the screen.
//!
//! Each slot lives at `<work_dir>/branding/logo-<slot>.<ext>` and is
//! served in preference to the bundled rainbow-horizon mark when present.
//! Borrowed-from-FleetDM: tenant chrome, same product.
//!
//! Legacy continuity: a pre-v0.5.2 single `logo.<ext>` (recorded under
//! the old `logo_filename`/`logo_mime` keys) is migrated on first load
//! into both the `dark` and `client` slots — that preserves the previous
//! behaviour (one mark fed both the dark WebUI and the PXE screen) until
//! the operator uploads dedicated variants.
//! //!
//! Storage policy mirrors `HostBindings` / `BootLog`: in-memory cache is //! Storage policy mirrors `HostBindings` / `BootLog`: in-memory cache is
//! authoritative for the current process, disk is the source of truth on //! authoritative for the current process, disk is the source of truth on
@@ -35,19 +47,104 @@ pub const ALLOWED_LOGO_MIMES: &[&str] = &[
/// puts a clear bound on memory + serialization cost. /// puts a clear bound on memory + serialization cost.
pub const MAX_LOGO_BYTES: usize = 2 * 1024 * 1024; pub const MAX_LOGO_BYTES: usize = 2 * 1024 * 1024;
/// Which branded surface a logo upload targets.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogoSlot {
/// WebUI + form-login page, light theme.
Light,
/// WebUI + form-login page, dark theme.
Dark,
/// iPXE boot-menu background seen by PXE clients.
Client,
}
impl LogoSlot {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
LogoSlot::Light => "light",
LogoSlot::Dark => "dark",
LogoSlot::Client => "client",
}
}
/// Parse a slot name from the URL path segment. Case-insensitive.
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"light" => Some(LogoSlot::Light),
"dark" => Some(LogoSlot::Dark),
"client" => Some(LogoSlot::Client),
_ => None,
}
}
}
/// One brand-mark slot: a filename (relative to the branding dir) plus
/// the MIME we cached at upload time so the HTTP layer can set the
/// Content-Type without re-sniffing.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Slot {
#[serde(default, skip_serializing_if = "Option::is_none")]
filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
mime: Option<String>,
}
impl Slot {
fn clear_file(&mut self, dir: &Path) {
if let Some(name) = self.filename.take() {
let _ = std::fs::remove_file(dir.join(name));
}
self.mime = None;
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Inner { struct Inner {
/// File name (relative to the branding dir) for the active logo, if #[serde(default)]
/// any. Always under `<work_dir>/branding/`; never an absolute path light: Slot,
/// from the operator. #[serde(default)]
dark: Slot,
#[serde(default)]
client: Slot,
/// Monotonic counter bumped on every set/clear (any slot). Surfaces
/// as a cache-bust token (`/assets/logo.svg?r=<rev>`) so the browser
/// fetches the new bytes the moment the operator swaps a logo — the
/// app version alone can't do this since it doesn't change on upload.
/// Persisted so the token stays stable across restarts and keeps
/// climbing across multiple swaps.
#[serde(default)]
rev: u64,
// ── Legacy (pre-v0.5.2) single-logo keys ──────────────────────────
// Read on load for one-way migration into `dark` + `client`, then
// dropped from the persisted form (skip_serializing_if).
#[serde(default, skip_serializing_if = "Option::is_none")]
logo_filename: Option<String>, logo_filename: Option<String>,
/// MIME of the active logo, mirroring `logo_filename`. Cached here #[serde(default, skip_serializing_if = "Option::is_none")]
/// so the HTTP layer can set Content-Type without re-sniffing.
logo_mime: Option<String>, logo_mime: Option<String>,
} }
impl Inner {
fn slot(&self, slot: LogoSlot) -> &Slot {
match slot {
LogoSlot::Light => &self.light,
LogoSlot::Dark => &self.dark,
LogoSlot::Client => &self.client,
}
}
fn slot_mut(&mut self, slot: LogoSlot) -> &mut Slot {
match slot {
LogoSlot::Light => &mut self.light,
LogoSlot::Dark => &mut self.dark,
LogoSlot::Client => &mut self.client,
}
}
}
/// In-memory + on-disk override registry. Cheap to clone; locks are /// In-memory + on-disk override registry. Cheap to clone; locks are
/// brief. The `branding.json` cache lives alongside the active asset /// brief. The `branding.json` cache lives alongside the active assets
/// inside `<work_dir>/branding/`. /// inside `<work_dir>/branding/`.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct BrandingStore { pub struct BrandingStore {
@@ -59,7 +156,8 @@ pub struct BrandingStore {
impl BrandingStore { impl BrandingStore {
/// Load (or initialise empty) from `<work_dir>/branding/`. Tolerates /// Load (or initialise empty) from `<work_dir>/branding/`. Tolerates
/// missing directories, partial state, and corrupt JSON — a bad /// missing directories, partial state, and corrupt JSON — a bad
/// cache should never block PXE for the network. /// cache should never block PXE for the network. Migrates a legacy
/// single-logo file into the dark + client slots.
#[must_use] #[must_use]
pub fn load_or_default(work_dir: &Path) -> Self { pub fn load_or_default(work_dir: &Path) -> Self {
let dir = work_dir.join("branding"); let dir = work_dir.join("branding");
@@ -67,25 +165,7 @@ impl BrandingStore {
let mut inner = Inner::default(); let mut inner = Inner::default();
if let Ok(text) = std::fs::read_to_string(&path) { if let Ok(text) = std::fs::read_to_string(&path) {
match serde_json::from_str::<Inner>(&text) { match serde_json::from_str::<Inner>(&text) {
Ok(parsed) => { Ok(parsed) => inner = parsed,
// Sanity: if the JSON says we have a logo but the
// file is gone, clear the in-memory pointer so
// /assets/logo.svg falls back to the bundled SVG
// rather than 500ing on a missing file.
if let Some(name) = parsed.logo_filename.as_deref() {
if dir.join(name).is_file() {
inner = parsed;
} else {
tracing::warn!(
target: "openpxe::branding",
file = %name,
"branding.json points at missing file; clearing"
);
}
} else {
inner = parsed;
}
}
Err(e) => { Err(e) => {
tracing::warn!( tracing::warn!(
target: "openpxe::branding", target: "openpxe::branding",
@@ -94,95 +174,245 @@ impl BrandingStore {
} }
} }
} }
Self { let store = Self {
dir: Arc::new(dir), dir: Arc::new(dir),
inner: Arc::new(RwLock::new(inner)), inner: Arc::new(RwLock::new(inner)),
};
store.migrate_legacy();
store.prune_missing();
store
}
/// One-way migration: a pre-v0.5.2 `logo.<ext>` becomes the dark +
/// client slots (the old single mark fed both the dark WebUI and the
/// PXE screen). Best-effort; failures leave the legacy file in place
/// rather than blocking startup.
fn migrate_legacy(&self) {
let (legacy_name, legacy_mime) = {
let g = self.inner.read();
(g.logo_filename.clone(), g.logo_mime.clone())
};
let Some(name) = legacy_name else { return };
let src = self.dir.join(&name);
if !src.is_file() {
// Legacy pointer is stale — just drop it.
let mut g = self.inner.write();
g.logo_filename = None;
g.logo_mime = None;
drop(g);
self.persist();
return;
} }
} let mime = legacy_mime.unwrap_or_else(|| "image/svg+xml".to_string());
let ext = ext_for_mime(&mime).unwrap_or("bin");
/// Absolute path to the active logo, if one is set and present on if let Ok(bytes) = std::fs::read(&src) {
/// disk. `None` means the HTTP layer should serve the bundled SVG. // Seed dark + client only when those slots are still empty so
#[must_use] // a re-run (or a manual edit) never clobbers operator intent.
pub fn logo_path(&self) -> Option<PathBuf> { let needs_dark = self.inner.read().dark.filename.is_none();
let g = self.inner.read(); let needs_client = self.inner.read().client.filename.is_none();
g.logo_filename.as_deref().map(|n| self.dir.join(n)) if needs_dark {
} let _ = self.write_slot(LogoSlot::Dark, &mime, ext, &bytes);
}
/// MIME of the active logo, if any. The HTTP layer pairs this with if needs_client {
/// the bytes returned by [`Self::logo_path`]. let _ = self.write_slot(LogoSlot::Client, &mime, ext, &bytes);
#[must_use]
pub fn logo_mime(&self) -> Option<String> {
self.inner.read().logo_mime.clone()
}
/// Replace the active logo. Returns the chosen on-disk filename so
/// the caller can echo it back in the API response. Old logos are
/// removed best-effort.
pub fn set_logo(&self, mime: &str, ext: &str, bytes: &[u8]) -> std::io::Result<String> {
std::fs::create_dir_all(self.dir.as_path())?;
// Single canonical filename per upload — overwriting the old one
// (after clearing it) keeps the directory tidy and avoids any
// path-traversal concern: the operator never supplies the name.
let safe_ext = sanitize_ext(ext);
let filename = format!("logo.{safe_ext}");
let final_path = self.dir.join(&filename);
// Atomic write: tmp -> rename. Guarantees the file is either
// entirely the old logo or entirely the new one.
let tmp = final_path.with_extension(format!("{safe_ext}.tmp"));
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, &final_path)?;
// Clean up any sibling logo.<otherext> so there's exactly one
// canonical file at any time.
if let Ok(entries) = std::fs::read_dir(self.dir.as_path()) {
for e in entries.flatten() {
let p = e.path();
let name = p
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
if name.starts_with("logo.") && name != filename {
let _ = std::fs::remove_file(&p);
}
} }
} }
let _ = std::fs::remove_file(&src);
{ {
let mut g = self.inner.write(); let mut g = self.inner.write();
g.logo_filename = Some(filename.clone()); g.logo_filename = None;
g.logo_mime = Some(mime.to_string()); g.logo_mime = None;
} }
self.persist(); self.persist();
tracing::info!( tracing::info!(
target: "openpxe::branding", target: "openpxe::branding",
file = %filename, mime = %mime, size = bytes.len(), "migrated legacy single logo into dark + client slots"
);
}
/// Drop in-memory slot pointers whose backing file vanished from disk
/// so the HTTP layer falls back to the bundled mark instead of 500ing.
fn prune_missing(&self) {
let mut changed = false;
{
let mut g = self.inner.write();
for slot in [LogoSlot::Light, LogoSlot::Dark, LogoSlot::Client] {
let present = g
.slot(slot)
.filename
.as_deref()
.is_some_and(|n| self.dir.join(n).is_file());
if !present && g.slot(slot).filename.is_some() {
g.slot_mut(slot).filename = None;
g.slot_mut(slot).mime = None;
changed = true;
}
}
}
if changed {
self.persist();
}
}
/// Absolute path to the logo for `slot`, if set and present on disk.
#[must_use]
pub fn slot_path(&self, slot: LogoSlot) -> Option<PathBuf> {
let g = self.inner.read();
g.slot(slot).filename.as_deref().map(|n| self.dir.join(n))
}
/// MIME of the logo for `slot`, if any.
#[must_use]
pub fn slot_mime(&self, slot: LogoSlot) -> Option<String> {
self.inner.read().slot(slot).mime.clone()
}
/// Resolve the WebUI logo for a theme, with fallback: light falls
/// back to dark and vice-versa, so a single uploaded variant still
/// shows on both themes. Returns `(path, mime)` or `None` (→ bundled).
#[must_use]
pub fn web_logo(&self, theme_is_light: bool) -> Option<(PathBuf, String)> {
let (primary, secondary) = if theme_is_light {
(LogoSlot::Light, LogoSlot::Dark)
} else {
(LogoSlot::Dark, LogoSlot::Light)
};
let g = self.inner.read();
let chosen = if g.slot(primary).filename.is_some() {
primary
} else {
secondary
};
let s = g.slot(chosen);
s.filename.as_deref().map(|n| {
(
self.dir.join(n),
s.mime
.clone()
.unwrap_or_else(|| "image/svg+xml".to_string()),
)
})
}
/// Resolve the PXE client logo (no theme fallback — the PXE screen
/// has a single mark). Returns `(path, mime)` or `None` (→ default
/// composed background).
#[must_use]
pub fn client_logo(&self) -> Option<(PathBuf, String)> {
let g = self.inner.read();
let s = &g.client;
s.filename.as_deref().map(|n| {
(
self.dir.join(n),
s.mime
.clone()
.unwrap_or_else(|| "application/octet-stream".to_string()),
)
})
}
/// Replace the logo for `slot`. Returns the chosen on-disk filename so
/// the caller can echo it back in the API response.
pub fn set_logo(
&self,
slot: LogoSlot,
mime: &str,
ext: &str,
bytes: &[u8],
) -> std::io::Result<String> {
let filename = self.write_slot(slot, mime, ext, bytes)?;
self.persist();
tracing::info!(
target: "openpxe::branding",
slot = slot.as_str(), file = %filename, mime = %mime, size = bytes.len(),
"custom logo installed" "custom logo installed"
); );
Ok(filename) Ok(filename)
} }
/// Drop the override and return to the bundled SVG. /// Write the bytes for a slot and update the in-memory pointer + rev,
pub fn clear_logo(&self) -> std::io::Result<()> { /// without persisting (the caller decides when to flush). Cleans up
let removed = { /// any sibling `logo-<slot>.*` so there's exactly one file per slot.
fn write_slot(
&self,
slot: LogoSlot,
mime: &str,
ext: &str,
bytes: &[u8],
) -> std::io::Result<String> {
std::fs::create_dir_all(self.dir.as_path())?;
let safe_ext = sanitize_ext(ext);
let stem = format!("logo-{}", slot.as_str());
let filename = format!("{stem}.{safe_ext}");
let final_path = self.dir.join(&filename);
// Atomic write: tmp -> rename.
let tmp = final_path.with_extension(format!("{safe_ext}.tmp"));
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, &final_path)?;
// Clean up any sibling `logo-<slot>.<otherext>`.
if let Ok(entries) = std::fs::read_dir(self.dir.as_path()) {
for e in entries.flatten() {
let p = e.path();
let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
if name.starts_with(&format!("{stem}.")) && name != filename {
let _ = std::fs::remove_file(&p);
}
}
}
let mut g = self.inner.write();
let s = g.slot_mut(slot);
s.filename = Some(filename.clone());
s.mime = Some(mime.to_string());
g.rev = g.rev.wrapping_add(1);
Ok(filename)
}
/// Drop the override for `slot` and return to the bundled / default.
pub fn clear_logo(&self, slot: LogoSlot) -> std::io::Result<()> {
{
let mut g = self.inner.write(); let mut g = self.inner.write();
let removed = g.logo_filename.take(); let dir = self.dir.as_path();
g.logo_mime = None; g.slot_mut(slot).clear_file(dir);
removed g.rev = g.rev.wrapping_add(1);
};
if let Some(name) = removed {
let p = self.dir.join(&name);
let _ = std::fs::remove_file(&p);
tracing::info!(target: "openpxe::branding", file = %name, "custom logo cleared");
} }
self.persist(); self.persist();
tracing::info!(target: "openpxe::branding", slot = slot.as_str(), "custom logo cleared");
Ok(()) Ok(())
} }
/// Convenience: true if a custom logo is configured. Surfaces on /// True if a custom logo is configured for `slot`.
/// `/api/status` so the WebUI can show "Custom logo: yes" without
/// fetching the asset itself.
#[must_use] #[must_use]
pub fn has_logo(&self) -> bool { pub fn has_logo(&self, slot: LogoSlot) -> bool {
self.inner.read().logo_filename.is_some() self.inner.read().slot(slot).filename.is_some()
}
/// True if either WebUI theme slot has a custom logo — drives the
/// FleetDM-style full-width brand block (and the `has-custom-logo`
/// class) on the sidebar + login page.
#[must_use]
pub fn has_any_web_logo(&self) -> bool {
let g = self.inner.read();
g.light.filename.is_some() || g.dark.filename.is_some()
}
/// Presence triple `(light, dark, client)` for the `/api/me` and
/// `/api/status` bootstrap payloads.
#[must_use]
pub fn presence(&self) -> (bool, bool, bool) {
let g = self.inner.read();
(
g.light.filename.is_some(),
g.dark.filename.is_some(),
g.client.filename.is_some(),
)
}
/// Cache-bust token for the logo asset URLs. Changes on every
/// set/clear so `/assets/logo.svg?r=<rev>` resolves to a fresh URL
/// whenever the operator swaps a brand mark. Stable otherwise.
#[must_use]
pub fn logo_rev(&self) -> u64 {
self.inner.read().rev
} }
fn persist(&self) { fn persist(&self) {
@@ -249,84 +479,180 @@ mod tests {
fn empty_after_load_when_no_branding_dir() { fn empty_after_load_when_no_branding_dir() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path()); let b = BrandingStore::load_or_default(dir.path());
assert!(!b.has_logo()); assert!(!b.has_logo(LogoSlot::Light));
assert!(b.logo_path().is_none()); assert!(!b.has_logo(LogoSlot::Dark));
assert!(b.logo_mime().is_none()); assert!(!b.has_logo(LogoSlot::Client));
assert!(b.web_logo(false).is_none());
assert!(b.client_logo().is_none());
assert!(!b.has_any_web_logo());
} }
#[test] #[test]
fn set_clear_round_trip_persists() { fn set_clear_round_trip_persists() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path()); let b = BrandingStore::load_or_default(dir.path());
let name = b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake").unwrap(); let name = b
assert_eq!(name, "logo.png"); .set_logo(LogoSlot::Dark, "image/png", "png", b"\x89PNG\r\n\x1a\nfake")
assert!(b.has_logo()); .unwrap();
assert_eq!(b.logo_mime().as_deref(), Some("image/png")); assert_eq!(name, "logo-dark.png");
let p = b.logo_path().unwrap(); assert!(b.has_logo(LogoSlot::Dark));
assert_eq!(b.slot_mime(LogoSlot::Dark).as_deref(), Some("image/png"));
let (p, _) = b.web_logo(false).unwrap();
assert!(p.is_file()); assert!(p.is_file());
// Re-open and confirm the override survives a restart. // Re-open and confirm the override survives a restart.
drop(b); drop(b);
let b2 = BrandingStore::load_or_default(dir.path()); let b2 = BrandingStore::load_or_default(dir.path());
assert!(b2.has_logo()); assert!(b2.has_logo(LogoSlot::Dark));
assert_eq!(b2.logo_mime().as_deref(), Some("image/png")); assert_eq!(b2.slot_mime(LogoSlot::Dark).as_deref(), Some("image/png"));
// Clear; the file goes away and has_logo flips off. // Clear; the file goes away and has_logo flips off.
b2.clear_logo().unwrap(); b2.clear_logo(LogoSlot::Dark).unwrap();
assert!(!b2.has_logo()); assert!(!b2.has_logo(LogoSlot::Dark));
assert!(!p.exists()); assert!(!p.exists());
} }
#[test] #[test]
fn replacing_logo_removes_old_extension_sibling() { fn web_logo_falls_back_across_themes() {
// PNG then SVG; only the SVG should remain on disk.
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path()); let b = BrandingStore::load_or_default(dir.path());
b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake").unwrap(); // Only dark uploaded — light theme falls back to it.
b.set_logo("image/svg+xml", "svg", br#"<svg xmlns="http://www.w3.org/2000/svg"/>"#).unwrap(); b.set_logo(LogoSlot::Dark, "image/png", "png", b"dark")
.unwrap();
let (p_light, _) = b.web_logo(true).expect("light falls back to dark");
assert!(p_light.ends_with("logo-dark.png"));
// Upload a distinct light — now light theme uses its own.
b.set_logo(LogoSlot::Light, "image/png", "png", b"light")
.unwrap();
let (p_light2, _) = b.web_logo(true).unwrap();
assert!(p_light2.ends_with("logo-light.png"));
// Client is independent and still unset.
assert!(b.client_logo().is_none());
}
#[test]
fn replacing_slot_removes_old_extension_sibling() {
let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path());
b.set_logo(
LogoSlot::Client,
"image/png",
"png",
b"\x89PNG\r\n\x1a\nfake",
)
.unwrap();
b.set_logo(
LogoSlot::Client,
"image/svg+xml",
"svg",
br#"<svg xmlns="http://www.w3.org/2000/svg"/>"#,
)
.unwrap();
let entries: Vec<_> = std::fs::read_dir(dir.path().join("branding")) let entries: Vec<_> = std::fs::read_dir(dir.path().join("branding"))
.unwrap() .unwrap()
.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned())) .filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned()))
.collect(); .collect();
assert!(entries.iter().any(|n| n == "logo.svg"), "got {entries:?}"); assert!(
assert!(!entries.iter().any(|n| n == "logo.png"), "stale PNG left over: {entries:?}"); entries.iter().any(|n| n == "logo-client.svg"),
"got {entries:?}"
);
assert!(
!entries.iter().any(|n| n == "logo-client.png"),
"stale PNG left over: {entries:?}"
);
}
#[test]
fn logo_rev_bumps_on_each_set_and_clear() {
let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path());
assert_eq!(b.logo_rev(), 0);
b.set_logo(LogoSlot::Light, "image/png", "png", b"a")
.unwrap();
assert_eq!(b.logo_rev(), 1);
b.set_logo(LogoSlot::Dark, "image/png", "png", b"b")
.unwrap();
assert_eq!(b.logo_rev(), 2);
b.clear_logo(LogoSlot::Light).unwrap();
assert_eq!(b.logo_rev(), 3);
drop(b);
let b2 = BrandingStore::load_or_default(dir.path());
assert_eq!(b2.logo_rev(), 3);
}
#[test]
fn legacy_single_logo_migrates_to_dark_and_client() {
// A pre-v0.5.2 branding.json + logo.png migrates on load.
let dir = tempdir().unwrap();
let brand_dir = dir.path().join("branding");
std::fs::create_dir_all(&brand_dir).unwrap();
std::fs::write(brand_dir.join("logo.png"), b"\x89PNG\r\n\x1a\nlegacy").unwrap();
// Hand-write the old shape (logo_filename/logo_mime, no slots).
std::fs::write(
brand_dir.join("branding.json"),
br#"{"logo_filename":"logo.png","logo_mime":"image/png","rev":4}"#,
)
.unwrap();
let b = BrandingStore::load_or_default(dir.path());
assert!(b.has_logo(LogoSlot::Dark), "dark seeded from legacy");
assert!(b.has_logo(LogoSlot::Client), "client seeded from legacy");
assert!(!b.has_logo(LogoSlot::Light), "light stays empty");
// The old logo.png is gone; per-slot files exist.
assert!(!brand_dir.join("logo.png").exists());
assert!(brand_dir.join("logo-dark.png").is_file());
assert!(brand_dir.join("logo-client.png").is_file());
// rev carried over from the legacy file and advanced as the two
// slots were seeded (each write bumps it), so it never regresses.
let migrated_rev = b.logo_rev();
assert!(
migrated_rev >= 4,
"rev should not regress below legacy: {migrated_rev}"
);
// And the migration is sticky across a restart (no re-migrate, no
// further rev churn).
drop(b);
let b2 = BrandingStore::load_or_default(dir.path());
assert!(b2.has_logo(LogoSlot::Dark));
assert!(b2.has_logo(LogoSlot::Client));
assert!(!b2.has_logo(LogoSlot::Light));
assert_eq!(b2.logo_rev(), migrated_rev, "restart must not re-migrate");
} }
#[test] #[test]
fn sanitize_ext_strips_separators_and_path_chars() { fn sanitize_ext_strips_separators_and_path_chars() {
assert_eq!(sanitize_ext("svg"), "svg"); assert_eq!(sanitize_ext("svg"), "svg");
// Path separators and non-alphanumerics filter out, leaving just
// letters. The remaining "etcpasswd" exceeds the 5-char cap so
// it collapses to `bin` rather than producing `etcpa`.
assert_eq!(sanitize_ext("../etc/passwd"), "bin"); assert_eq!(sanitize_ext("../etc/passwd"), "bin");
// Short alphanumeric strip-through stays itself.
assert_eq!(sanitize_ext("../svg"), "svg"); assert_eq!(sanitize_ext("../svg"), "svg");
assert_eq!(sanitize_ext(""), "bin"); assert_eq!(sanitize_ext(""), "bin");
assert_eq!(sanitize_ext("PNG"), "png"); assert_eq!(sanitize_ext("PNG"), "png");
// Anything past five chars is suspicious — collapse to `bin`.
assert_eq!(sanitize_ext("svgvvvv"), "bin"); assert_eq!(sanitize_ext("svgvvvv"), "bin");
} }
#[test] #[test]
fn missing_file_referenced_by_json_resolves_to_empty() { fn missing_file_referenced_by_json_resolves_to_empty() {
// If the operator nukes the file out from under the JSON cache,
// we should silently fall back to no-override rather than
// hanging on to a bogus path.
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let brand_dir = dir.path().join("branding"); let brand_dir = dir.path().join("branding");
std::fs::create_dir_all(&brand_dir).unwrap(); std::fs::create_dir_all(&brand_dir).unwrap();
// Hand-write a branding.json claiming logo.png exists. // branding.json claims a dark slot whose file doesn't exist.
let inner = Inner {
logo_filename: Some("logo.png".into()),
logo_mime: Some("image/png".into()),
};
std::fs::write( std::fs::write(
brand_dir.join("branding.json"), brand_dir.join("branding.json"),
serde_json::to_vec_pretty(&inner).unwrap(), br#"{"dark":{"filename":"logo-dark.png","mime":"image/png"},"rev":1}"#,
) )
.unwrap(); .unwrap();
let b = BrandingStore::load_or_default(dir.path()); let b = BrandingStore::load_or_default(dir.path());
assert!(!b.has_logo(), "should fall back when referenced file is missing"); assert!(
!b.has_logo(LogoSlot::Dark),
"should fall back when referenced file is missing"
);
}
#[test]
fn slot_parse_round_trips() {
assert_eq!(LogoSlot::parse("light"), Some(LogoSlot::Light));
assert_eq!(LogoSlot::parse("DARK"), Some(LogoSlot::Dark));
assert_eq!(LogoSlot::parse(" client "), Some(LogoSlot::Client));
assert_eq!(LogoSlot::parse("nope"), None);
assert_eq!(LogoSlot::Light.as_str(), "light");
} }
#[test] #[test]
+165 -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};
@@ -56,6 +58,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,
} }
@@ -74,6 +79,11 @@ pub struct Paths {
/// Only used when `settings.windows_enabled = true`. Defaults to /// Only used when `settings.windows_enabled = true`. Defaults to
/// `/var/lib/openpxe/smb` in the container image. /// `/var/lib/openpxe/smb` in the container image.
pub smb_dir: PathBuf, pub smb_dir: PathBuf,
/// v0.5.2: directory holding uploaded unattended-install answer files
/// (Kickstart / Preseed / Autoinstall / Windows answer files). Kept
/// separate from `iso_dir` so answer files never appear in the ISO
/// listing or the PXE menu. Defaults to `/var/lib/openpxe/unattended`.
pub unattended_dir: PathBuf,
} }
impl Default for ServerConfig { impl Default for ServerConfig {
@@ -109,6 +119,7 @@ impl Default for Paths {
ipxe_dir: PathBuf::from("/usr/share/openpxe/ipxe"), ipxe_dir: PathBuf::from("/usr/share/openpxe/ipxe"),
wimboot_path: None, wimboot_path: None,
smb_dir: PathBuf::from("/var/lib/openpxe/smb"), smb_dir: PathBuf::from("/var/lib/openpxe/smb"),
unattended_dir: PathBuf::from("/var/lib/openpxe/unattended"),
} }
} }
} }
@@ -123,48 +134,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()
if let Ok(v) = std::env::var("OPENPXE_ISO_DIR") { })
self.paths.iso_dir = PathBuf::from(v); .split(".")
} }
if let Ok(v) = std::env::var("OPENPXE_WORK_DIR") {
self.paths.work_dir = PathBuf::from(v); #[cfg(test)]
} mod tests {
if let Ok(v) = std::env::var("OPENPXE_IPXE_DIR") { // figment's `Jail::expect_with` closure returns `Result<(), figment::Error>`
self.paths.ipxe_dir = PathBuf::from(v); // and `figment::Error` is large; that's the library's API, not ours.
} #![allow(clippy::result_large_err)]
if let Ok(v) = std::env::var("OPENPXE_SMB_DIR") { use super::*;
self.paths.smb_dir = PathBuf::from(v);
} #[test]
fn defaults_load_when_no_file_or_env() {
figment::Jail::expect_with(|_jail| {
let c = Config::load(None).expect("load defaults");
assert_eq!(c.server.http_port, 80);
assert_eq!(c.network.dhcp_mode, DhcpMode::Proxy);
assert_eq!(c.paths.iso_dir, PathBuf::from("/var/lib/openpxe/isos"));
Ok(())
});
}
#[test]
fn legacy_flat_env_vars_still_apply() {
figment::Jail::expect_with(|jail| {
jail.set_env("OPENPXE_HTTP_PORT", "8123");
jail.set_env("OPENPXE_TFTP_PORT", "6900");
jail.set_env("OPENPXE_DHCP_PORT", "6767");
jail.set_env("OPENPXE_PXE_PORT", "4444");
jail.set_env("OPENPXE_PUBLIC_IP", "10.20.30.40");
jail.set_env("OPENPXE_DHCP_MODE", "disabled");
jail.set_env("OPENPXE_ISO_DIR", "/data/isos");
jail.set_env("OPENPXE_WORK_DIR", "/data/work");
jail.set_env("OPENPXE_IPXE_DIR", "/data/ipxe");
jail.set_env("OPENPXE_SMB_DIR", "/data/smb");
// v0.5.4: a field the old apply_env never covered.
jail.set_env("OPENPXE_UNATTENDED_DIR", "/data/unattended");
let c = Config::load(None).expect("load with env");
assert_eq!(c.server.http_port, 8123);
assert_eq!(c.server.tftp_port, 6900);
assert_eq!(c.network.dhcp_port, 6767);
assert_eq!(c.network.pxe_port, 4444);
assert_eq!(c.server.public_ip, Some("10.20.30.40".parse().unwrap()));
assert_eq!(c.network.dhcp_mode, DhcpMode::Disabled);
assert_eq!(c.paths.iso_dir, PathBuf::from("/data/isos"));
assert_eq!(c.paths.work_dir, PathBuf::from("/data/work"));
assert_eq!(c.paths.ipxe_dir, PathBuf::from("/data/ipxe"));
assert_eq!(c.paths.smb_dir, PathBuf::from("/data/smb"));
assert_eq!(c.paths.unattended_dir, PathBuf::from("/data/unattended"));
Ok(())
});
}
#[test]
fn dhcp_mode_off_alias_maps_to_disabled() {
figment::Jail::expect_with(|jail| {
jail.set_env("OPENPXE_DHCP_MODE", "off");
let c = Config::load(None).unwrap();
assert_eq!(c.network.dhcp_mode, DhcpMode::Disabled);
Ok(())
});
}
#[test]
fn nested_double_underscore_form_also_works() {
figment::Jail::expect_with(|jail| {
jail.set_env("OPENPXE_SERVER__HTTP_PORT", "9001");
let c = Config::load(None).unwrap();
assert_eq!(c.server.http_port, 9001);
Ok(())
});
}
#[test]
fn env_overrides_toml_file() {
figment::Jail::expect_with(|jail| {
jail.create_file(
"openpxe.toml",
"[server]\nhttp_port = 8080\n[paths]\niso_dir = \"/from/toml\"\n",
)?;
jail.set_env("OPENPXE_HTTP_PORT", "8443");
let c = Config::load(Some(Path::new("openpxe.toml"))).unwrap();
// env wins over TOML…
assert_eq!(c.server.http_port, 8443);
// …but TOML-only values still apply.
assert_eq!(c.paths.iso_dir, PathBuf::from("/from/toml"));
Ok(())
});
}
#[test]
fn unrelated_openpxe_env_vars_are_ignored() {
figment::Jail::expect_with(|jail| {
// entrypoint.sh sets these; they must not break config load.
jail.set_env("OPENPXE_UID", "10001");
jail.set_env("OPENPXE_CONFIG", "/etc/openpxe.toml");
let c = Config::load(None).expect("stray vars ignored");
assert_eq!(c.server.http_port, 80);
Ok(())
});
} }
} }
+65
View File
@@ -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");
}
}
+67 -7
View File
@@ -21,6 +21,8 @@ use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use time::OffsetDateTime; use time::OffsetDateTime;
use crate::profile::DeployProfile;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostBinding { pub struct HostBinding {
/// Lowercase, colon-separated MAC (e.g. `aa:bb:cc:dd:ee:ff`). The /// Lowercase, colon-separated MAC (e.g. `aa:bb:cc:dd:ee:ff`). The
@@ -35,6 +37,11 @@ pub struct HostBinding {
/// `"rack-3 spine"`). Empty if unset. /// `"rack-3 spine"`). Empty if unset.
#[serde(default)] #[serde(default)]
pub label: String, pub label: String,
/// v0.5.2: optional unattended-install hints (auto hostname / IP /
/// answer-file id). Flattened into the binding JSON so pre-v0.5.2
/// `hosts.json` files (which lack these keys) still deserialize.
#[serde(default, flatten)]
pub profile: DeployProfile,
#[serde(with = "time::serde::rfc3339")] #[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime, pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")] #[serde(with = "time::serde::rfc3339")]
@@ -94,20 +101,31 @@ impl HostBindings {
} }
/// Insert or update. Returns the resulting binding (with timestamps). /// Insert or update. Returns the resulting binding (with timestamps).
pub fn upsert(&self, mac: &str, target: &str, label: &str) -> HostBinding { /// The `profile` carries optional unattended-install hints (v0.5.2);
/// pass `DeployProfile::default()` for a plain pin.
pub fn upsert(
&self,
mac: &str,
target: &str,
label: &str,
profile: DeployProfile,
) -> HostBinding {
let key = normalize_mac(mac); let key = normalize_mac(mac);
let now = OffsetDateTime::now_utc(); let now = OffsetDateTime::now_utc();
let profile = profile.normalized();
let binding = { let binding = {
let mut g = self.inner.write(); let mut g = self.inner.write();
let entry = g.by_mac.entry(key.clone()).or_insert_with(|| HostBinding { let entry = g.by_mac.entry(key.clone()).or_insert_with(|| HostBinding {
mac: key.clone(), mac: key.clone(),
target: target.to_string(), target: target.to_string(),
label: label.to_string(), label: label.to_string(),
profile: profile.clone(),
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });
entry.target = target.to_string(); entry.target = target.to_string();
entry.label = label.to_string(); entry.label = label.to_string();
entry.profile = profile.clone();
entry.updated_at = now; entry.updated_at = now;
entry.clone() entry.clone()
}; };
@@ -179,6 +197,10 @@ mod tests {
use super::*; use super::*;
use tempfile::tempdir; use tempfile::tempdir;
fn np() -> DeployProfile {
DeployProfile::default()
}
#[test] #[test]
fn normalize_handles_case_and_dashes() { fn normalize_handles_case_and_dashes() {
assert_eq!(normalize_mac("AA:BB:CC:DD:EE:FF"), "aa:bb:cc:dd:ee:ff"); assert_eq!(normalize_mac("AA:BB:CC:DD:EE:FF"), "aa:bb:cc:dd:ee:ff");
@@ -191,7 +213,12 @@ mod tests {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path()); let h = HostBindings::load_or_default(dir.path());
assert!(h.is_empty()); assert!(h.is_empty());
h.upsert("AA:BB:CC:00:00:01", "ubuntu-24-04-linux", "rack-3 spine"); h.upsert(
"AA:BB:CC:00:00:01",
"ubuntu-24-04-linux",
"rack-3 spine",
np(),
);
let found = h.lookup("aa-bb-cc-00-00-01").expect("lookup"); let found = h.lookup("aa-bb-cc-00-00-01").expect("lookup");
assert_eq!(found.target, "ubuntu-24-04-linux"); assert_eq!(found.target, "ubuntu-24-04-linux");
assert_eq!(found.label, "rack-3 spine"); assert_eq!(found.label, "rack-3 spine");
@@ -202,8 +229,8 @@ mod tests {
fn upsert_replaces_existing_target() { fn upsert_replaces_existing_target() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path()); let h = HostBindings::load_or_default(dir.path());
h.upsert("aa:bb:cc:00:00:01", "old-target", "label1"); h.upsert("aa:bb:cc:00:00:01", "old-target", "label1", np());
h.upsert("aa:bb:cc:00:00:01", "new-target", "label2"); h.upsert("aa:bb:cc:00:00:01", "new-target", "label2", np());
assert_eq!(h.len(), 1); assert_eq!(h.len(), 1);
let b = h.lookup("aa:bb:cc:00:00:01").unwrap(); let b = h.lookup("aa:bb:cc:00:00:01").unwrap();
assert_eq!(b.target, "new-target"); assert_eq!(b.target, "new-target");
@@ -214,7 +241,7 @@ mod tests {
fn remove_works_and_reports_outcome() { fn remove_works_and_reports_outcome() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path()); let h = HostBindings::load_or_default(dir.path());
h.upsert("aa:bb:cc:00:00:01", "x", ""); h.upsert("aa:bb:cc:00:00:01", "x", "", np());
assert!(h.remove("AA:BB:CC:00:00:01")); assert!(h.remove("AA:BB:CC:00:00:01"));
assert!(!h.remove("aa:bb:cc:00:00:01")); // already gone assert!(!h.remove("aa:bb:cc:00:00:01")); // already gone
assert!(h.is_empty()); assert!(h.is_empty());
@@ -224,11 +251,44 @@ mod tests {
fn round_trip_persists_to_disk() { fn round_trip_persists_to_disk() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path()); let h = HostBindings::load_or_default(dir.path());
h.upsert("aa:bb:cc:00:00:01", "ubuntu-linux", "rack-3"); h.upsert("aa:bb:cc:00:00:01", "ubuntu-linux", "rack-3", np());
h.upsert("aa:bb:cc:00:00:02", "_local", "tom-laptop"); h.upsert("aa:bb:cc:00:00:02", "_local", "tom-laptop", np());
drop(h); drop(h);
let h2 = HostBindings::load_or_default(dir.path()); let h2 = HostBindings::load_or_default(dir.path());
assert_eq!(h2.len(), 2); assert_eq!(h2.len(), 2);
assert_eq!(h2.lookup("aa:bb:cc:00:00:02").unwrap().target, "_local"); assert_eq!(h2.lookup("aa:bb:cc:00:00:02").unwrap().target, "_local");
} }
#[test]
fn profile_round_trips_to_disk() {
let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path());
let prof = DeployProfile {
auto_hostname: Some("node-7".into()),
auto_ip: Some("10.0.0.7".into()),
unattended_file: Some("ubuntu-ks".into()),
};
h.upsert("aa:bb:cc:00:00:09", "ubuntu-linux", "lab", prof);
drop(h);
let h2 = HostBindings::load_or_default(dir.path());
let b = h2.lookup("aa:bb:cc:00:00:09").unwrap();
assert_eq!(b.profile.auto_hostname.as_deref(), Some("node-7"));
assert_eq!(b.profile.auto_ip.as_deref(), Some("10.0.0.7"));
assert_eq!(b.profile.unattended_file.as_deref(), Some("ubuntu-ks"));
}
#[test]
fn legacy_hosts_json_without_profile_still_loads() {
// A pre-v0.5.2 hosts.json has no profile keys at all.
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("hosts.json"),
br#"[{"mac":"aa:bb:cc:00:00:01","target":"_local","label":"old","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}]"#,
)
.unwrap();
let h = HostBindings::load_or_default(dir.path());
let b = h.lookup("aa:bb:cc:00:00:01").unwrap();
assert_eq!(b.target, "_local");
assert!(b.profile.is_empty());
}
} }
+10 -2
View File
@@ -8,24 +8,32 @@ pub mod boot_log;
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;
pub mod metrics; pub mod metrics;
pub mod notify;
pub mod profile;
pub mod queue; pub mod queue;
pub mod saml;
pub mod settings; pub mod settings;
pub mod sso; pub mod sso;
pub mod wol;
pub use arch::{ClientArch, FirmwareClass}; pub use arch::{ClientArch, 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 branding::{ext_for_mime, BrandingStore, 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 sso::{SsoConfig, SsoStore};
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig}; pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
pub use error::{Error, Result}; pub use error::{Error, Result};
pub use host_bindings::{normalize_mac, HostBinding, HostBindings}; pub use host_bindings::{normalize_mac, HostBinding, HostBindings};
pub use log_bus::{LogBus, LogBusLayer, LogLine}; pub use log_bus::{LogBus, LogBusLayer, LogLine};
pub use metrics::{HttpRoute, Metrics}; pub use metrics::{HttpRoute, Metrics};
pub use notify::{NotifyConfig, NotifyKind, NotifyStore};
pub use profile::DeployProfile;
pub use queue::{DeploymentQueue, QueueEntry}; pub use queue::{DeploymentQueue, QueueEntry};
pub use saml::{IdpMetadata, SamlError, SpParams, VerifiedPrincipal, VerifiedResponse};
pub use settings::{Settings, SettingsStore, TimeoutAction}; pub use settings::{Settings, SettingsStore, TimeoutAction};
pub use sso::{SsoConfig, SsoStore};
+362
View File
@@ -0,0 +1,362 @@
//! Webhook / email notification configuration.
//!
//! v0.5.0: OpenPXE can ping a chat webhook or send an email when
//! something noteworthy happens (a machine PXE-booted an image, a
//! deployment was assigned, a WoL was sent). One active provider at a
//! time, chosen by `kind` — dead-simple for an L1 tech: pick Slack,
//! paste the incoming-webhook URL, done.
//!
//! This module owns only the *configuration* (validation + persistence
//! to `<work_dir>/notify.json`). The actual sending — HTTP POST for the
//! chat providers, SMTP for email — lives in the http-api crate, which
//! already carries an HTTP client and the SMTP dependency. Keeping the
//! network I/O out of `core` matches how `BrandingStore`/`SsoStore`
//! stay pure config stores.
//!
//! Secrets note: the SMTP password is persisted in `notify.json`
//! alongside the rest of the config (0644 like the other state files).
//! It is never echoed back through the API — the snapshot used for the
//! GET response blanks it (see `Self::redacted`).
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use crate::{Error, Result};
const MAX_URL_LEN: usize = 2048;
const MAX_FIELD_LEN: usize = 512;
/// Which notification transport is active.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NotifyKind {
/// Slack incoming webhook (`{ "text": ... }`).
#[default]
Slack,
/// Discord webhook (`{ "content": ... }`).
Discord,
/// Microsoft Teams incoming webhook (legacy MessageCard JSON).
Teams,
/// Email via SMTP.
Smtp,
}
impl NotifyKind {
/// True when this kind drives a chat webhook (POST a JSON body to a
/// single URL) rather than SMTP.
#[must_use]
pub fn is_webhook(self) -> bool {
matches!(self, Self::Slack | Self::Discord | Self::Teams)
}
}
/// Operator-configurable notification settings. Single provider active
/// at a time; the inactive fields are kept so switching providers
/// doesn't wipe the other one's values.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NotifyConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub kind: NotifyKind,
/// Incoming-webhook URL for Slack / Discord / Teams.
#[serde(default)]
pub webhook_url: String,
// ── SMTP fields (used when kind == Smtp) ──
#[serde(default)]
pub smtp_host: String,
#[serde(default = "default_smtp_port")]
pub smtp_port: u16,
#[serde(default)]
pub smtp_username: String,
#[serde(default)]
pub smtp_password: String,
/// `From:` address. Falls back to `smtp_username` when blank.
#[serde(default)]
pub smtp_from: String,
/// `To:` address (single recipient — keep it simple).
#[serde(default)]
pub smtp_to: String,
/// Use implicit TLS (port 465). When false we use STARTTLS on the
/// configured port (587 typical). Either way the connection is
/// encrypted — we never offer plaintext SMTP.
#[serde(default)]
pub smtp_implicit_tls: bool,
}
fn default_smtp_port() -> u16 {
587
}
impl NotifyConfig {
/// True when enabled and the active provider has the fields it
/// needs to actually send.
#[must_use]
pub fn is_usable(&self) -> bool {
if !self.enabled {
return false;
}
if self.kind.is_webhook() {
!self.webhook_url.trim().is_empty()
} else {
!self.smtp_host.trim().is_empty() && !self.smtp_to.trim().is_empty()
}
}
/// A copy safe to return over the API: the SMTP password is blanked
/// (replaced with a non-empty sentinel only when one is set, so the
/// UI can show "configured" without leaking it).
#[must_use]
pub fn redacted(&self) -> NotifyConfig {
let mut c = self.clone();
if !c.smtp_password.is_empty() {
c.smtp_password = SECRET_SENTINEL.to_string();
}
c
}
}
/// Returned by the API in place of a stored password. When the UI PUTs
/// this value back unchanged we keep the existing password rather than
/// overwriting it with the sentinel.
pub const SECRET_SENTINEL: &str = "__keep__";
/// In-memory + on-disk notification config registry.
#[derive(Debug, Clone)]
pub struct NotifyStore {
path: Arc<PathBuf>,
inner: Arc<RwLock<NotifyConfig>>,
}
impl NotifyStore {
#[must_use]
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("notify.json");
let cfg = match std::fs::read_to_string(&path) {
Ok(text) => serde_json::from_str::<NotifyConfig>(&text).unwrap_or_else(|e| {
tracing::warn!(
target: "openpxe::notify",
"notify.json unreadable ({e}); starting with defaults"
);
NotifyConfig::default()
}),
Err(_) => NotifyConfig::default(),
};
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(cfg)),
}
}
#[must_use]
pub fn snapshot(&self) -> NotifyConfig {
self.inner.read().clone()
}
/// Replace the whole config. `incoming.smtp_password == SECRET_SENTINEL`
/// is treated as "keep the existing password" so the UI never has to
/// round-trip the real secret.
pub fn replace(&self, mut incoming: NotifyConfig) -> Result<NotifyConfig> {
incoming.webhook_url = incoming.webhook_url.trim().to_string();
incoming.smtp_host = incoming.smtp_host.trim().to_string();
incoming.smtp_username = incoming.smtp_username.trim().to_string();
incoming.smtp_from = incoming.smtp_from.trim().to_string();
incoming.smtp_to = incoming.smtp_to.trim().to_string();
// Preserve the stored password when the UI sends the sentinel.
if incoming.smtp_password == SECRET_SENTINEL {
incoming
.smtp_password
.clone_from(&self.inner.read().smtp_password);
}
// Length caps.
if incoming.webhook_url.len() > MAX_URL_LEN {
return Err(Error::Invalid(format!(
"webhook URL exceeds {MAX_URL_LEN}-char cap"
)));
}
for (name, v) in [
("smtp_host", &incoming.smtp_host),
("smtp_username", &incoming.smtp_username),
("smtp_from", &incoming.smtp_from),
("smtp_to", &incoming.smtp_to),
] {
if v.len() > MAX_FIELD_LEN {
return Err(Error::Invalid(format!(
"{name} exceeds {MAX_FIELD_LEN}-char cap"
)));
}
}
// Validate the active provider only when enabling.
if incoming.enabled {
if incoming.kind.is_webhook() {
if incoming.webhook_url.is_empty() {
return Err(Error::Invalid(
"a webhook URL is required to enable chat notifications".into(),
));
}
if !incoming.webhook_url.starts_with("https://")
&& !incoming.webhook_url.starts_with("http://")
{
return Err(Error::Invalid(
"webhook URL must start with http:// or https://".into(),
));
}
} else {
if incoming.smtp_host.is_empty() {
return Err(Error::Invalid(
"SMTP host is required to enable email notifications".into(),
));
}
if incoming.smtp_to.is_empty() {
return Err(Error::Invalid(
"a recipient (To) is required to enable email notifications".into(),
));
}
if incoming.smtp_port == 0 {
return Err(Error::Invalid("SMTP port must be non-zero".into()));
}
}
}
{
let mut g = self.inner.write();
*g = incoming.clone();
}
self.persist();
tracing::info!(
target: "openpxe::notify",
enabled = incoming.enabled, kind = ?incoming.kind,
"notification configuration updated"
);
Ok(incoming)
}
fn persist(&self) {
let snap = self.inner.read().clone();
let body = match serde_json::to_vec_pretty(&snap) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::notify", "serialize notify.json: {e}");
return;
}
};
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = self.path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::notify", "write notify.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::warn!(target: "openpxe::notify", "rename notify.json: {e}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn default_disabled_not_usable() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
assert!(!s.snapshot().enabled);
assert!(!s.snapshot().is_usable());
}
#[test]
fn slack_requires_url_when_enabled() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
let r = s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Slack,
..Default::default()
});
assert!(matches!(r, Err(Error::Invalid(_))));
s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Slack,
webhook_url: "https://hooks.slack.com/services/XXX".into(),
..Default::default()
})
.unwrap();
assert!(s.snapshot().is_usable());
}
#[test]
fn smtp_requires_host_and_recipient() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
let r = s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Smtp,
smtp_host: "smtp.example.com".into(),
..Default::default()
});
assert!(matches!(r, Err(Error::Invalid(_))), "missing recipient should reject");
s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Smtp,
smtp_host: "smtp.example.com".into(),
smtp_port: 587,
smtp_to: "[email protected]".into(),
smtp_from: "[email protected]".into(),
..Default::default()
})
.unwrap();
assert!(s.snapshot().is_usable());
}
#[test]
fn password_sentinel_preserves_stored_secret() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Smtp,
smtp_host: "smtp.example.com".into(),
smtp_port: 587,
smtp_to: "[email protected]".into(),
smtp_password: "s3cret".into(),
..Default::default()
})
.unwrap();
// Redacted snapshot hides the password behind the sentinel.
assert_eq!(s.snapshot().redacted().smtp_password, SECRET_SENTINEL);
// PUTting the sentinel back keeps the real password.
s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Smtp,
smtp_host: "smtp.example.com".into(),
smtp_port: 587,
smtp_to: "[email protected]".into(),
smtp_password: SECRET_SENTINEL.into(),
..Default::default()
})
.unwrap();
assert_eq!(s.snapshot().smtp_password, "s3cret");
}
#[test]
fn webhook_url_scheme_enforced() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
let r = s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Discord,
webhook_url: "ftp://example.com/hook".into(),
..Default::default()
});
assert!(matches!(r, Err(Error::Invalid(_))));
}
}
+122
View File
@@ -0,0 +1,122 @@
//! Per-host deployment profile.
//!
//! v0.5.2: a small, optional bundle of "what should this machine do when
//! it images" attached to either a pinned host binding ([`crate::HostBinding`])
//! or a queued device ([`crate::QueueEntry`]). All three fields are
//! optional and independent:
//!
//! * `auto_hostname` — substituted into the served unattended answer file
//! (`{{HOSTNAME}}`) so the installer sets the machine name.
//! * `auto_ip` — substituted as `{{IP}}`. OpenPXE is a DHCP **proxy** and
//! does not hand out leases, so this is applied by the installer as a
//! static-network directive inside the answer file, not by DHCP.
//! * `unattended_file` — the id of an uploaded file in the unattended
//! store (Kickstart / Preseed / Autoinstall / Windows answer file). When
//! set, the boot chain injects the appropriate kernel argument so the
//! install runs unattended.
use serde::{Deserialize, Serialize};
/// Optional deployment hints carried on a host pin or a queue entry.
///
/// The fields are flattened into `HostBinding` / `QueueEntry` on the wire
/// (so existing JSON stays compatible via `#[serde(default)]`); this type
/// is the in-code bundle the boot chain consumes.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeployProfile {
/// Hostname to set on the imaged machine (`{{HOSTNAME}}`). Empty/None
/// leaves the installer default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_hostname: Option<String>,
/// Static IPv4/IPv6 the installer should configure (`{{IP}}`). Stored
/// as a free-form string — validated lightly at the HTTP layer.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_ip: Option<String>,
/// Id of an uploaded file in the unattended store. Empty/None means
/// "no unattended install — boot interactively".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unattended_file: Option<String>,
}
/// Cap on the stored hostname / IP strings — generous for any real value
/// but bounds what an operator can stuff into the JSON.
pub const MAX_PROFILE_FIELD_LEN: usize = 255;
impl DeployProfile {
/// True when nothing is set — lets call sites skip work entirely.
#[must_use]
pub fn is_empty(&self) -> bool {
self.auto_hostname.is_none() && self.auto_ip.is_none() && self.unattended_file.is_none()
}
/// True when an unattended file is selected (drives boot-chain injection).
#[must_use]
pub fn has_unattended(&self) -> bool {
self.unattended_file
.as_deref()
.is_some_and(|s| !s.trim().is_empty())
}
/// Normalise: trim every field and collapse empty strings to `None`
/// so persisted JSON never carries `""` for an unset value.
#[must_use]
pub fn normalized(mut self) -> Self {
fn clean(v: Option<String>) -> Option<String> {
v.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.map(|s| s.chars().take(MAX_PROFILE_FIELD_LEN).collect())
}
self.auto_hostname = clean(self.auto_hostname);
self.auto_ip = clean(self.auto_ip);
self.unattended_file = clean(self.unattended_file);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_profile_is_empty() {
assert!(DeployProfile::default().is_empty());
assert!(!DeployProfile::default().has_unattended());
}
#[test]
fn normalize_trims_and_nulls_empty() {
let p = DeployProfile {
auto_hostname: Some(" node-7 ".into()),
auto_ip: Some(" ".into()),
unattended_file: Some(String::new()),
}
.normalized();
assert_eq!(p.auto_hostname.as_deref(), Some("node-7"));
assert_eq!(p.auto_ip, None);
assert_eq!(p.unattended_file, None);
assert!(!p.is_empty());
}
#[test]
fn has_unattended_detects_real_id() {
let p = DeployProfile {
unattended_file: Some("ubuntu-ks".into()),
..Default::default()
};
assert!(p.has_unattended());
}
#[test]
fn long_field_is_capped() {
let long = "a".repeat(1000);
let p = DeployProfile {
auto_hostname: Some(long),
..Default::default()
}
.normalized();
assert_eq!(
p.auto_hostname.as_deref().map(str::len),
Some(MAX_PROFILE_FIELD_LEN)
);
}
}
+32
View File
@@ -21,6 +21,7 @@ use time::OffsetDateTime;
use tokio::sync::Notify; use tokio::sync::Notify;
use uuid::Uuid; use uuid::Uuid;
use crate::profile::DeployProfile;
use crate::ClientArch; use crate::ClientArch;
/// Per-client queue state visible to the WebUI. /// Per-client queue state visible to the WebUI.
@@ -37,6 +38,11 @@ pub struct QueueEntry {
#[serde(with = "time::serde::rfc3339")] #[serde(with = "time::serde::rfc3339")]
pub last_poll_at: OffsetDateTime, pub last_poll_at: OffsetDateTime,
pub assigned_target: Option<String>, pub assigned_target: Option<String>,
/// v0.5.2: optional per-device deployment profile set via the queue
/// "Profile" button (auto hostname / IP / unattended file). Flattened
/// so the JSON stays flat alongside the other queue fields.
#[serde(default, flatten)]
pub profile: DeployProfile,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -49,6 +55,7 @@ struct QueueEntryInner {
joined_at: OffsetDateTime, joined_at: OffsetDateTime,
last_poll_at: OffsetDateTime, last_poll_at: OffsetDateTime,
assigned_target: Option<String>, assigned_target: Option<String>,
profile: DeployProfile,
/// Broadcast primitive that wakes the long-poll as soon as an /// Broadcast primitive that wakes the long-poll as soon as an
/// assignment lands — no polling on our side, no sleep-loops. /// assignment lands — no polling on our side, no sleep-loops.
notify: Arc<Notify>, notify: Arc<Notify>,
@@ -65,6 +72,7 @@ impl QueueEntryInner {
joined_at: self.joined_at, joined_at: self.joined_at,
last_poll_at: self.last_poll_at, last_poll_at: self.last_poll_at,
assigned_target: self.assigned_target.clone(), assigned_target: self.assigned_target.clone(),
profile: self.profile.clone(),
} }
} }
} }
@@ -110,6 +118,7 @@ impl DeploymentQueue {
joined_at: now, joined_at: now,
last_poll_at: now, last_poll_at: now,
assigned_target: None, assigned_target: None,
profile: DeployProfile::default(),
notify: Arc::new(Notify::new()), notify: Arc::new(Notify::new()),
}; };
let snap = inner.snapshot(); let snap = inner.snapshot();
@@ -133,6 +142,29 @@ impl DeploymentQueue {
Some(g.snapshot()) Some(g.snapshot())
} }
/// Operator sets (or clears) the deployment profile for a queued
/// device via the WebUI "Profile" button. Returns the updated
/// snapshot, or `None` if the entry has since been released.
pub fn set_profile(&self, entry_id: &str, profile: DeployProfile) -> Option<QueueEntry> {
let mut guard = self.inner.write();
let g = guard.get_mut(entry_id)?;
g.profile = profile.normalized();
Some(g.snapshot())
}
/// Look up the deployment profile for a queued MAC, if any. Used by
/// the boot chain to inject an unattended file / template the
/// hostname + IP when an assigned device chains to its target.
#[must_use]
pub fn profile_for_mac(&self, mac: &str) -> Option<DeployProfile> {
let guard = self.inner.read();
guard
.values()
.find(|g| g.mac == mac)
.map(|g| g.profile.clone())
.filter(|p| !p.is_empty())
}
/// Operator assigns an ISO entry (boot_entry id) to one or more clients. /// Operator assigns an ISO entry (boot_entry id) to one or more clients.
/// Returns the number of queue entries that were updated. Entries not in the /// Returns the number of queue entries that were updated. Entries not in the
/// queue are silently skipped. /// queue are silently skipped.
+159
View File
@@ -0,0 +1,159 @@
//! AuthnRequest construction + HTTP-Redirect binding encoding.
//!
//! For SP-initiated login we build an `<AuthnRequest>`, then encode it for the
//! HTTP-Redirect binding: raw DEFLATE (RFC 1951) → base64 → percent-encode,
//! appended as the `SAMLRequest` query parameter. AuthnRequests are sent
//! unsigned in this release (the IdP must not require client signatures).
use std::io::Write as _;
use base64::Engine;
use flate2::write::DeflateEncoder;
use flate2::Compression;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use super::{SamlError, SpParams};
use crate::encoding::{pct_encode, xml_escape};
const NS_PROTOCOL: &str = "urn:oasis:names:tc:SAML:2.0:protocol";
const NS_ASSERTION: &str = "urn:oasis:names:tc:SAML:2.0:assertion";
const NAMEID_EMAIL: &str = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
const BINDING_POST: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
/// A built AuthnRequest, ready to redirect the browser to the IdP.
#[derive(Debug, Clone)]
pub struct AuthnRequest {
/// The request `ID` — the caller records this so the matching response's
/// `InResponseTo` can be correlated (replay/CSRF protection).
pub id: String,
/// The full IdP URL to 302 the browser to (includes `SAMLRequest` and,
/// when supplied, `RelayState`).
pub location: String,
}
/// Build an AuthnRequest targeting `idp_sso_url` and encode it for the
/// HTTP-Redirect binding. `relay_state`, if given, round-trips back to us via
/// the response (we use it to send the operator to their intended page).
pub fn build(
sp: &SpParams,
idp_sso_url: &str,
relay_state: Option<&str>,
) -> Result<AuthnRequest, SamlError> {
let id = format!("_{}", uuid::Uuid::new_v4().simple());
let issue_instant = OffsetDateTime::now_utc()
.replace_nanosecond(0)
.unwrap_or_else(|_| OffsetDateTime::now_utc())
.format(&Rfc3339)
.map_err(|e| SamlError::Timestamp(e.to_string()))?;
let xml = format!(
r#"<samlp:AuthnRequest xmlns:samlp="{NS_PROTOCOL}" xmlns:saml="{NS_ASSERTION}" ID="{id}" Version="2.0" IssueInstant="{instant}" Destination="{dest}" ProtocolBinding="{BINDING_POST}" AssertionConsumerServiceURL="{acs}"><saml:Issuer>{issuer}</saml:Issuer><samlp:NameIDPolicy Format="{NAMEID_EMAIL}" AllowCreate="true"/></samlp:AuthnRequest>"#,
instant = issue_instant,
dest = xml_escape(idp_sso_url),
acs = xml_escape(&sp.acs_url),
issuer = xml_escape(&sp.entity_id),
);
let encoded = deflate_base64(&xml)?;
let sep = if idp_sso_url.contains('?') { '&' } else { '?' };
let mut location = format!("{idp_sso_url}{sep}SAMLRequest={}", pct_encode(&encoded));
if let Some(rs) = relay_state {
location.push_str("&RelayState=");
location.push_str(&pct_encode(rs));
}
Ok(AuthnRequest { id, location })
}
/// Raw-DEFLATE then base64 — the HTTP-Redirect binding's `SAMLRequest` payload.
fn deflate_base64(xml: &str) -> Result<String, SamlError> {
let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
enc.write_all(xml.as_bytes())
.and_then(|()| enc.try_finish())
.map_err(|e| SamlError::Xml(format!("deflate: {e}")))?;
let compressed = enc
.finish()
.map_err(|e| SamlError::Xml(format!("deflate: {e}")))?;
Ok(base64::engine::general_purpose::STANDARD.encode(compressed))
}
// `pct_encode` + `xml_escape` now live in `openpxe_core::encoding` (v0.5.4)
// — imported above.
#[cfg(test)]
mod tests {
use super::*;
use flate2::read::DeflateDecoder;
use std::io::Read;
fn sp() -> SpParams {
SpParams {
entity_id: "https://pxe.example.com".into(),
acs_url: "https://pxe.example.com/api/sso/acs".into(),
}
}
fn pct_decode(s: &str) -> Vec<u8> {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = (bytes[i + 1] as char).to_digit(16).unwrap();
let lo = (bytes[i + 2] as char).to_digit(16).unwrap();
out.push((hi * 16 + lo) as u8);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
out
}
#[test]
fn id_is_ncname_and_location_has_request() {
let req = build(&sp(), "https://idp.example.com/sso", Some("/dashboard")).unwrap();
assert!(req.id.starts_with('_'));
assert!(req
.location
.starts_with("https://idp.example.com/sso?SAMLRequest="));
assert!(req.location.contains("&RelayState=%2Fdashboard"));
}
#[test]
fn redirect_payload_round_trips_to_our_authn_request() {
let req = build(&sp(), "https://idp.example.com/sso", None).unwrap();
// Pull SAMLRequest value out of the query string.
let q = req.location.split("SAMLRequest=").nth(1).unwrap();
let val = q.split('&').next().unwrap();
let compressed = base64::engine::general_purpose::STANDARD
.decode(pct_decode(val))
.unwrap();
let mut inflate = DeflateDecoder::new(&compressed[..]);
let mut xml = String::new();
inflate.read_to_string(&mut xml).unwrap();
let doc = roxmltree::Document::parse(&xml).unwrap();
let root = doc.root_element();
assert_eq!(root.tag_name().name(), "AuthnRequest");
assert_eq!(root.attribute("ID").unwrap(), req.id);
assert_eq!(
root.attribute("AssertionConsumerServiceURL").unwrap(),
"https://pxe.example.com/api/sso/acs"
);
let issuer = root
.descendants()
.find(|n| n.tag_name().name() == "Issuer")
.unwrap();
assert_eq!(issuer.text().unwrap(), "https://pxe.example.com");
}
#[test]
fn existing_query_uses_ampersand_separator() {
let req = build(&sp(), "https://idp.example.com/sso?foo=bar", None).unwrap();
assert!(req.location.contains("?foo=bar&SAMLRequest="));
}
}
+228
View File
@@ -0,0 +1,228 @@
//! IdP metadata parsing + SP metadata generation.
//!
//! We parse only what the SP flow needs: the IdP Entity ID, its
//! `SingleSignOnService` endpoints (HTTP-Redirect / HTTP-POST), and the
//! X.509 signing certificate(s). Everything else in the document is ignored.
use base64::Engine;
use super::{SamlError, SpParams};
use crate::encoding::xml_escape;
/// SAML 2.0 binding URIs.
pub const BINDING_REDIRECT: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect";
pub const BINDING_POST: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
/// The subset of an IdP's `EntityDescriptor` the SP flow consumes.
#[derive(Debug, Clone)]
pub struct IdpMetadata {
/// The IdP's Entity ID — we require incoming assertions to be issued by it.
pub entity_id: String,
/// SSO endpoint for the HTTP-Redirect binding (where we send AuthnRequests).
pub sso_redirect_url: Option<String>,
/// SSO endpoint for the HTTP-POST binding (fallback target).
pub sso_post_url: Option<String>,
/// DER-encoded X.509 signing certificate(s). More than one appears during
/// key rotation; verification tries each.
pub signing_certs_der: Vec<Vec<u8>>,
}
impl IdpMetadata {
/// Parse an IdP `EntityDescriptor` document.
///
/// Robust to namespace-prefix variation (matches on local element names),
/// since IdPs disagree on prefixes (`md:`, `ns0:`, default, …).
pub fn parse(xml: &str) -> Result<Self, SamlError> {
let doc = roxmltree::Document::parse(xml).map_err(|e| SamlError::Xml(e.to_string()))?;
let root = doc.root_element();
// The signing IDP descriptor. Some metadata wraps multiple
// descriptors (AA, SP) in one document; we want IDPSSODescriptor.
let idp_desc = root
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "IDPSSODescriptor")
.ok_or_else(|| SamlError::Metadata("IDPSSODescriptor".into()))?;
// Entity ID lives on the EntityDescriptor (root, or an ancestor of the
// IDPSSODescriptor when several are nested).
let entity_id = idp_desc
.ancestors()
.find_map(|n| {
if n.tag_name().name() == "EntityDescriptor" {
n.attribute("entityID")
} else {
None
}
})
.or_else(|| root.attribute("entityID"))
.map(str::to_owned)
.ok_or_else(|| SamlError::Metadata("entityID".into()))?;
let mut sso_redirect_url = None;
let mut sso_post_url = None;
for sso in idp_desc
.children()
.filter(|n| n.is_element() && n.tag_name().name() == "SingleSignOnService")
{
let binding = sso.attribute("Binding").unwrap_or("");
let location = sso.attribute("Location").map(str::to_owned);
match binding {
BINDING_REDIRECT if sso_redirect_url.is_none() => sso_redirect_url = location,
BINDING_POST if sso_post_url.is_none() => sso_post_url = location,
_ => {}
}
}
// Signing certs: KeyDescriptor with use="signing" or no use attribute
// (a bare KeyDescriptor is valid for both signing and encryption).
let mut signing_certs_der = Vec::new();
for kd in idp_desc
.children()
.filter(|n| n.is_element() && n.tag_name().name() == "KeyDescriptor")
{
match kd.attribute("use") {
Some("signing") | None => {}
Some(_) => continue, // encryption-only key — skip
}
for cert_node in kd
.descendants()
.filter(|n| n.is_element() && n.tag_name().name() == "X509Certificate")
{
let b64: String = node_text(&cert_node)
.chars()
.filter(|c| !c.is_whitespace())
.collect();
if b64.is_empty() {
continue;
}
let der = base64::engine::general_purpose::STANDARD
.decode(b64.as_bytes())
.map_err(|e| SamlError::Base64(e.to_string()))?;
signing_certs_der.push(der);
}
}
if signing_certs_der.is_empty() {
return Err(SamlError::NoSigningCert);
}
Ok(Self {
entity_id,
sso_redirect_url,
sso_post_url,
signing_certs_der,
})
}
/// Preferred SSO destination for an outbound AuthnRequest: HTTP-Redirect
/// if advertised, otherwise HTTP-POST.
pub fn sso_destination(&self) -> Option<&str> {
self.sso_redirect_url
.as_deref()
.or(self.sso_post_url.as_deref())
}
}
/// Build our SP `EntityDescriptor` XML so an IdP admin can import OpenPXE as a
/// relying party. Advertises the ACS URL (HTTP-POST binding) and an emailAddress
/// NameID format — matching what the response path expects.
pub fn build_sp_metadata(sp: &SpParams) -> String {
let entity = xml_escape(&sp.entity_id);
let acs = xml_escape(&sp.acs_url);
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{entity}">
<SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
<AssertionConsumerService Binding="{BINDING_POST}" Location="{acs}" index="0" isDefault="true"/>
</SPSSODescriptor>
</EntityDescriptor>
"#
)
}
/// Collect the concatenated text of an element's direct text children.
fn node_text(n: &roxmltree::Node<'_, '_>) -> String {
n.children()
.filter(roxmltree::Node::is_text)
.filter_map(|c| c.text())
.collect()
}
// `xml_escape` now lives in `openpxe_core::encoding` (v0.5.4) — imported above.
#[cfg(test)]
mod tests {
use super::*;
// A trimmed-down Keycloak-style IdP descriptor (cert body is a stand-in;
// signing tests build real certs in the parent module's tests).
const SAMPLE: &str = r#"<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
entityID="https://idp.example.com/realms/fleet">
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo><ds:X509Data><ds:X509Certificate>
QUJDREVG
</ds:X509Certificate></ds:X509Data></ds:KeyInfo>
</md:KeyDescriptor>
<md:KeyDescriptor use="encryption">
<ds:KeyInfo><ds:X509Data><ds:X509Certificate>WlpaWg==</ds:X509Certificate></ds:X509Data></ds:KeyInfo>
</md:KeyDescriptor>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="https://idp.example.com/realms/fleet/protocol/saml"/>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://idp.example.com/realms/fleet/protocol/saml"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>"#;
#[test]
fn parses_entity_sso_and_signing_cert() {
let m = IdpMetadata::parse(SAMPLE).unwrap();
assert_eq!(m.entity_id, "https://idp.example.com/realms/fleet");
assert_eq!(
m.sso_redirect_url.as_deref(),
Some("https://idp.example.com/realms/fleet/protocol/saml")
);
assert!(m.sso_post_url.is_some());
// Only the signing KeyDescriptor's cert is collected (ABCDEF), not the
// encryption one (ZZZZ).
assert_eq!(m.signing_certs_der.len(), 1);
assert_eq!(m.signing_certs_der[0], b"ABCDEF");
}
#[test]
fn missing_signing_cert_is_rejected() {
let xml = r#"<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="x">
<IDPSSODescriptor>
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://x/sso"/>
</IDPSSODescriptor></EntityDescriptor>"#;
assert!(matches!(
IdpMetadata::parse(xml),
Err(SamlError::NoSigningCert)
));
}
#[test]
fn missing_idp_descriptor_is_rejected() {
let xml = r#"<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="x"></EntityDescriptor>"#;
assert!(matches!(
IdpMetadata::parse(xml),
Err(SamlError::Metadata(_))
));
}
#[test]
fn sp_metadata_contains_entity_and_acs() {
let sp = SpParams {
entity_id: "https://pxe.example.com".into(),
acs_url: "https://pxe.example.com/api/sso/acs".into(),
};
let xml = build_sp_metadata(&sp);
assert!(xml.contains(r#"entityID="https://pxe.example.com""#));
assert!(xml.contains("https://pxe.example.com/api/sso/acs"));
assert!(xml.contains(BINDING_POST));
// Must be well-formed.
roxmltree::Document::parse(&xml).unwrap();
}
}
+92
View File
@@ -0,0 +1,92 @@
//! Pure-Rust SAML 2.0 Service Provider (v0.5.1).
//!
//! This module implements the SP half of a SAML Web-Browser-SSO profile:
//!
//! * [`metadata`] — parse the IdP's `EntityDescriptor` (SSO URLs + signing
//! certificates) and build *our* SP metadata for the IdP admin to import.
//! * [`authn_request`] — build an `AuthnRequest` and encode it for the
//! HTTP-Redirect binding.
//! * [`response`] — decode a `SAMLResponse`, **verify its XML signature**
//! against the IdP's pinned certificate (via the pure-Rust `bergshamra`
//! crate — no OpenSSL/libxml2/xmlsec, so the static musl binary stays
//! C-free), then enforce the SP-side semantic checks (Status, Destination,
//! Audience, time bounds) that are where SAML SPs actually get attacked.
//!
//! Stateful checks (replay of assertion IDs, correlating `InResponseTo`
//! against requests *we* issued, gating IdP-initiated login) live in the
//! HTTP layer — [`response::consume`] is deliberately stateless and returns
//! the IDs the caller needs to perform them.
//!
//! Access model: any assertion the IdP authenticates and we cryptographically
//! verify yields an operator [`VerifiedPrincipal`]. OpenPXE is single-tier —
//! there is no per-user role table — and the local admin account remains a
//! guaranteed fallback owner regardless of SSO state.
pub mod authn_request;
pub mod metadata;
pub mod response;
pub use authn_request::AuthnRequest;
pub use metadata::IdpMetadata;
pub use response::{VerifiedPrincipal, VerifiedResponse};
use thiserror::Error;
/// Default clock-skew tolerance applied to assertion time bounds. SAML IdPs
/// and SPs rarely have perfectly synced clocks; 60s matches common practice
/// (Shibboleth/FleetDM defaults are in this ballpark).
pub const DEFAULT_CLOCK_SKEW_SECS: i64 = 60;
/// Runtime SP parameters, derived from [`crate::SsoConfig`] + the advertised
/// public base URL by the HTTP layer.
#[derive(Debug, Clone)]
pub struct SpParams {
/// Our SP Entity ID (the `<Issuer>` we send and the `Audience` we require
/// in responses). Defaults to the public base URL when the operator left
/// the Entity ID field blank.
pub entity_id: String,
/// The Assertion Consumer Service URL the IdP POSTs the response to —
/// `<public_base_url>/api/sso/acs`.
pub acs_url: String,
}
/// Everything that can go wrong consuming a SAML response. Kept coarse on
/// purpose: the HTTP layer logs the detail and shows the operator a generic
/// "SSO sign-in failed" — we never leak which specific check tripped to the
/// browser, since that aids an attacker probing the SP.
#[derive(Debug, Error)]
pub enum SamlError {
#[error("SAML XML parse error: {0}")]
Xml(String),
#[error("IdP metadata is missing a required element: {0}")]
Metadata(String),
#[error("no usable IdP signing certificate in metadata")]
NoSigningCert,
#[error("signature verification failed: {0}")]
Signature(String),
#[error("the signature does not cover the assertion we read")]
SignatureScope,
#[error("SAML response status was not Success: {0}")]
Status(String),
#[error("response is missing a required element: {0}")]
MissingElement(String),
#[error("encrypted assertions are not supported in this release")]
EncryptedAssertionUnsupported,
#[error("expected exactly one assertion, found {0}")]
AssertionCount(usize),
#[error("issuer mismatch: response was not issued by the configured IdP")]
IssuerMismatch,
#[error("audience mismatch: assertion is not addressed to this service provider")]
AudienceMismatch,
#[error("response destination does not match our ACS URL")]
DestinationMismatch,
#[error("assertion is expired or not yet valid")]
TimeBounds,
#[error("invalid SAML timestamp: {0}")]
Timestamp(String),
#[error("base64 decode failed: {0}")]
Base64(String),
}
#[cfg(test)]
mod tests;
+294
View File
@@ -0,0 +1,294 @@
//! SAMLResponse consumption: signature verification + SP-side validation.
//!
//! [`consume`] is intentionally **stateless** — it verifies the XML signature
//! against the IdP's pinned certificate(s) and enforces every check that can
//! be made from the response alone (Status, Destination, Issuer, Audience,
//! time bounds, signature scope). It then returns the `assertion_id` and
//! `in_response_to` so the HTTP layer can perform the *stateful* checks it
//! owns: replay rejection, correlating the request we issued, and gating
//! IdP-initiated login.
use roxmltree::{Document, Node};
use time::format_description::well_known::Rfc3339;
use time::{Duration, OffsetDateTime};
use super::metadata::IdpMetadata;
use super::{SamlError, SpParams};
const STATUS_SUCCESS: &str = "urn:oasis:names:tc:SAML:2.0:status:Success";
/// The verified subject of a SAML assertion. OpenPXE is single-tier, so this
/// is all an operator session needs.
#[derive(Debug, Clone)]
pub struct VerifiedPrincipal {
/// The `<NameID>` value (an email, per our requested NameID format).
pub name_id: String,
/// Email used as the session identity. Equals `name_id` for the
/// emailAddress NameID format.
pub email: String,
/// Human-readable display name, if the IdP sent one as an attribute.
pub display_name: Option<String>,
}
/// Result of a successful [`consume`]. The IDs/expiry feed the HTTP layer's
/// stateful checks.
#[derive(Debug, Clone)]
pub struct VerifiedResponse {
pub principal: VerifiedPrincipal,
/// `InResponseTo` from the response, if present. `None` = unsolicited
/// (IdP-initiated) — the HTTP layer only accepts that when the operator
/// enabled it.
pub in_response_to: Option<String>,
/// The assertion's `ID` — used by the caller as the replay-guard key.
pub assertion_id: String,
/// The assertion's expiry (`Conditions/@NotOnOrAfter`) — the replay
/// guard can drop the consumed ID after this instant.
pub assertion_expiry: OffsetDateTime,
/// `AuthnStatement/@SessionIndex`, if present (useful for future SLO).
pub session_index: Option<String>,
}
/// Verify and validate a decoded `SAMLResponse` XML document.
pub fn consume(
xml: &str,
sp: &SpParams,
idp: &IdpMetadata,
now: OffsetDateTime,
clock_skew: Duration,
) -> Result<VerifiedResponse, SamlError> {
// 1. Cryptographically verify the signature against the pinned IdP cert(s).
// `trusted_keys_only` ignores any cert embedded in the document's
// KeyInfo, so an attacker can't substitute their own key.
let verified_uris = verify_signature(xml, &idp.signing_certs_der)?;
// 2. Parse for semantic validation.
let doc = Document::parse(xml).map_err(|e| SamlError::Xml(e.to_string()))?;
let root = doc.root_element();
if root.tag_name().name() != "Response" {
return Err(SamlError::MissingElement("Response".into()));
}
let response_id = root.attribute("ID").map(str::to_owned);
let in_response_to = root.attribute("InResponseTo").map(str::to_owned);
// 3. Status must be Success.
let status_value = root
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "StatusCode")
.and_then(|sc| sc.attribute("Value"))
.unwrap_or("");
if status_value != STATUS_SUCCESS && !status_value.ends_with(":Success") {
return Err(SamlError::Status(status_value.to_owned()));
}
// 4. Destination (if the IdP set one) must be our ACS.
if let Some(dest) = root.attribute("Destination") {
if !urls_equal(dest, &sp.acs_url) {
return Err(SamlError::DestinationMismatch);
}
}
// 5. Exactly one (unencrypted) Assertion.
if root
.descendants()
.any(|n| n.is_element() && n.tag_name().name() == "EncryptedAssertion")
{
return Err(SamlError::EncryptedAssertionUnsupported);
}
let assertions: Vec<Node<'_, '_>> = root
.children()
.filter(|c| c.is_element() && c.tag_name().name() == "Assertion")
.collect();
if assertions.len() != 1 {
return Err(SamlError::AssertionCount(assertions.len()));
}
let assertion = assertions[0];
let assertion_id = assertion
.attribute("ID")
.map(str::to_owned)
.ok_or_else(|| SamlError::MissingElement("Assertion/@ID".into()))?;
// 6. The signature must actually cover the assertion we're about to trust:
// either the assertion itself, the enclosing response, or the whole
// document. (bergshamra's strict_verification already constrains where
// the signed element may sit; this ties it to *our* assertion.)
let covers_assertion = verified_uris.iter().any(|u| {
u.is_empty()
|| u == &format!("#{assertion_id}")
|| response_id
.as_ref()
.is_some_and(|rid| u == &format!("#{rid}"))
});
if !covers_assertion {
return Err(SamlError::SignatureScope);
}
// 7. Issuer must be the configured IdP.
let issuer = first_child(assertion, "Issuer")
.map(text_of)
.unwrap_or_default();
if !idp.entity_id.is_empty() && issuer != idp.entity_id {
return Err(SamlError::IssuerMismatch);
}
// 8. Subject → NameID + SubjectConfirmationData time/recipient checks.
let subject = first_child(assertion, "Subject")
.ok_or_else(|| SamlError::MissingElement("Subject".into()))?;
let name_id = first_child(subject, "NameID")
.map(text_of)
.filter(|s| !s.is_empty())
.ok_or_else(|| SamlError::MissingElement("NameID".into()))?;
if let Some(scd) = subject
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "SubjectConfirmationData")
{
if let Some(recipient) = scd.attribute("Recipient") {
if !urls_equal(recipient, &sp.acs_url) {
return Err(SamlError::DestinationMismatch);
}
}
if let Some(noa) = scd.attribute("NotOnOrAfter") {
let noa = parse_instant(noa)?;
if now >= noa + clock_skew {
return Err(SamlError::TimeBounds);
}
}
}
// 9. Conditions: time window + audience.
let conditions = first_child(assertion, "Conditions");
if let Some(cond) = conditions {
if let Some(nb) = cond.attribute("NotBefore") {
let nb = parse_instant(nb)?;
if now < nb - clock_skew {
return Err(SamlError::TimeBounds);
}
}
}
let assertion_expiry = conditions
.and_then(|c| c.attribute("NotOnOrAfter"))
.map(parse_instant)
.transpose()?
.ok_or_else(|| SamlError::MissingElement("Conditions/@NotOnOrAfter".into()))?;
if now >= assertion_expiry + clock_skew {
return Err(SamlError::TimeBounds);
}
let audience_ok = conditions.is_some_and(|c| {
c.descendants()
.filter(|n| n.is_element() && n.tag_name().name() == "Audience")
.any(|a| text_of(a) == sp.entity_id)
});
if !audience_ok {
return Err(SamlError::AudienceMismatch);
}
// 10. Optional: SessionIndex + display-name attribute.
let session_index = assertion
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "AuthnStatement")
.and_then(|a| a.attribute("SessionIndex"))
.map(str::to_owned);
let display_name = extract_display_name(assertion);
Ok(VerifiedResponse {
principal: VerifiedPrincipal {
email: name_id.clone(),
name_id,
display_name,
},
in_response_to,
assertion_id,
assertion_expiry,
session_index,
})
}
/// Verify the document's XML-DSig against each pinned IdP cert in turn
/// (handles key rotation), returning the verified `<Reference>` URIs.
fn verify_signature(xml: &str, certs_der: &[Vec<u8>]) -> Result<Vec<String>, SamlError> {
let mut last_err = String::from("no signing certificate matched");
for der in certs_der {
let key = match bergshamra::keys::loader::load_x509_cert_der(der) {
Ok(k) => k,
Err(e) => {
last_err = e.to_string();
continue;
}
};
let mut km = bergshamra::keys::KeysManager::new();
km.add_key(key);
// trusted_keys_only: only ever trust the pinned IdP key, never an
// inline KeyInfo cert. strict_verification: XSW positional defense.
let ctx = bergshamra::DsigContext::new(km)
.with_trusted_keys_only(true)
.with_strict_verification(true);
match bergshamra::verify(&ctx, xml) {
Ok(bergshamra::VerifyResult::Valid { references, .. }) => {
return Ok(references.into_iter().map(|r| r.uri).collect());
}
Ok(bergshamra::VerifyResult::Invalid { reason }) => last_err = reason,
Err(e) => last_err = e.to_string(),
}
}
Err(SamlError::Signature(last_err))
}
/// Pull a display name from the assertion's attribute statement, trying the
/// common attribute names IdPs use (FleetDM checks the same set).
fn extract_display_name(assertion: Node<'_, '_>) -> Option<String> {
const WANTED: &[&str] = &[
"name",
"displayname",
"cn",
"urn:oid:2.5.4.3",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
];
for attr in assertion
.descendants()
.filter(|n| n.is_element() && n.tag_name().name() == "Attribute")
{
let key = attr
.attribute("Name")
.or_else(|| attr.attribute("FriendlyName"))
.unwrap_or("")
.to_ascii_lowercase();
if WANTED.contains(&key.as_str()) {
if let Some(val) = attr
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "AttributeValue")
{
let v = text_of(val);
if !v.is_empty() {
return Some(v);
}
}
}
}
None
}
fn first_child<'a, 'i>(n: Node<'a, 'i>, local: &str) -> Option<Node<'a, 'i>> {
n.children()
.find(|c| c.is_element() && c.tag_name().name() == local)
}
fn text_of(n: Node<'_, '_>) -> String {
n.children()
.filter(Node::is_text)
.filter_map(|c| c.text())
.collect::<String>()
.trim()
.to_owned()
}
/// Parse an `xs:dateTime` (e.g. `2026-05-31T12:00:00.000Z`).
fn parse_instant(s: &str) -> Result<OffsetDateTime, SamlError> {
OffsetDateTime::parse(s.trim(), &Rfc3339).map_err(|e| SamlError::Timestamp(format!("{s}: {e}")))
}
/// Compare two URLs for SAML endpoint-matching purposes: exact, or differing
/// only by a single trailing slash.
fn urls_equal(a: &str, b: &str) -> bool {
a == b || a.trim_end_matches('/') == b.trim_end_matches('/')
}
+287
View File
@@ -0,0 +1,287 @@
//! End-to-end SAML SP tests.
//!
//! We mint a throwaway self-signed cert/key with `rcgen`, sign a SAML Response
//! template with `bergshamra::sign` (the same engine that verifies it), and
//! drive [`response::consume`] through the accept path and every reject path.
//! This proves both the signature wiring and the SP-semantic checks.
use time::format_description::well_known::Rfc3339;
use time::{Duration, OffsetDateTime};
use super::metadata::IdpMetadata;
use super::{response, SamlError, SpParams};
const SP_ENTITY: &str = "https://pxe.example.com";
const ACS: &str = "https://pxe.example.com/api/sso/acs";
const IDP_ENTITY: &str = "https://idp.example.com/realms/fleet";
const EMAIL: &str = "[email protected]";
struct TestIdp {
cert_der: Vec<u8>,
key_pem: String,
}
fn test_idp() -> TestIdp {
let ck = rcgen::generate_simple_self_signed(vec!["idp.example.com".to_string()]).unwrap();
TestIdp {
cert_der: ck.cert.der().as_ref().to_vec(),
key_pem: ck.key_pair.serialize_pem(),
}
}
fn fmt(t: OffsetDateTime) -> String {
t.replace_nanosecond(0).unwrap().format(&Rfc3339).unwrap()
}
/// Knobs for building a response template — defaults are a valid response.
struct Resp {
issuer: String,
audience: String,
status: String,
not_before: OffsetDateTime,
not_on_or_after: OffsetDateTime,
in_response_to: Option<String>,
recipient: String,
}
impl Default for Resp {
fn default() -> Self {
let now = OffsetDateTime::now_utc();
Self {
issuer: IDP_ENTITY.into(),
audience: SP_ENTITY.into(),
status: "urn:oasis:names:tc:SAML:2.0:status:Success".into(),
not_before: now - Duration::minutes(5),
not_on_or_after: now + Duration::hours(1),
in_response_to: Some("_req-abc".into()),
recipient: ACS.into(),
}
}
}
impl Resp {
/// The unsigned template (a `<ds:Signature>` with empty values).
fn template(&self) -> String {
let now = fmt(OffsetDateTime::now_utc());
let irt = self
.in_response_to
.as_ref()
.map(|v| format!(r#" InResponseTo="{v}""#))
.unwrap_or_default();
format!(
r##"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_resp1" Version="2.0" IssueInstant="{now}" Destination="{ACS}"{irt}>
<saml:Issuer>{issuer}</saml:Issuer>
<samlp:Status><samlp:StatusCode Value="{status}"/></samlp:Status>
<saml:Assertion ID="_assertion1" Version="2.0" IssueInstant="{now}">
<saml:Issuer>{issuer}</saml:Issuer>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256"/>
<ds:Reference URI="#_assertion1">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue></ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue></ds:SignatureValue>
</ds:Signature>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">{EMAIL}</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData Recipient="{recipient}" NotOnOrAfter="{noa}"{irt}/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="{nb}" NotOnOrAfter="{noa}">
<saml:AudienceRestriction><saml:Audience>{audience}</saml:Audience></saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="{now}" SessionIndex="sess-123">
<saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef></saml:AuthnContext>
</saml:AuthnStatement>
<saml:AttributeStatement>
<saml:Attribute Name="displayName"><saml:AttributeValue>Miles Ward</saml:AttributeValue></saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
</samlp:Response>"##,
issuer = self.issuer,
status = self.status,
audience = self.audience,
recipient = self.recipient,
nb = fmt(self.not_before),
noa = fmt(self.not_on_or_after),
)
}
}
fn sign(template: &str, key_pem: &str) -> String {
let key = bergshamra::keys::loader::load_pem_auto(key_pem.as_bytes(), None)
.expect("load test signing key");
let mut km = bergshamra::keys::KeysManager::new();
km.add_key(key);
let ctx = bergshamra::DsigContext::new(km);
bergshamra::sign(&ctx, template).expect("sign test response")
}
fn sp() -> SpParams {
SpParams {
entity_id: SP_ENTITY.into(),
acs_url: ACS.into(),
}
}
fn idp(cert_der: Vec<u8>) -> IdpMetadata {
IdpMetadata {
entity_id: IDP_ENTITY.into(),
sso_redirect_url: None,
sso_post_url: None,
signing_certs_der: vec![cert_der],
}
}
fn consume(xml: &str, cert_der: Vec<u8>) -> Result<response::VerifiedResponse, SamlError> {
response::consume(
xml,
&sp(),
&idp(cert_der),
OffsetDateTime::now_utc(),
Duration::seconds(60),
)
}
#[test]
fn good_response_yields_principal() {
let t = test_idp();
let signed = sign(&Resp::default().template(), &t.key_pem);
let out = consume(&signed, t.cert_der).expect("valid response should verify");
assert_eq!(out.principal.email, EMAIL);
assert_eq!(out.principal.name_id, EMAIL);
assert_eq!(out.principal.display_name.as_deref(), Some("Miles Ward"));
assert_eq!(out.in_response_to.as_deref(), Some("_req-abc"));
assert_eq!(out.assertion_id, "_assertion1");
assert_eq!(out.session_index.as_deref(), Some("sess-123"));
}
#[test]
fn tampered_assertion_is_rejected() {
let t = test_idp();
let signed = sign(&Resp::default().template(), &t.key_pem);
// Flip the subject email after signing — breaks the digest.
let tampered = signed.replace(EMAIL, "[email protected]");
assert_ne!(signed, tampered);
assert!(matches!(
consume(&tampered, t.cert_der),
Err(SamlError::Signature(_) | SamlError::SignatureScope)
));
}
#[test]
fn unsigned_response_is_rejected() {
let t = test_idp();
// Feed the *unsigned* template (empty SignatureValue) straight in.
let unsigned = Resp::default().template();
assert!(matches!(
consume(&unsigned, t.cert_der),
Err(SamlError::Signature(_))
));
}
#[test]
fn wrong_signing_key_is_rejected() {
let signer = test_idp();
let other = test_idp(); // different keypair pinned as the "IdP" cert
let signed = sign(&Resp::default().template(), &signer.key_pem);
assert!(matches!(
consume(&signed, other.cert_der),
Err(SamlError::Signature(_))
));
}
#[test]
fn wrong_audience_is_rejected() {
let t = test_idp();
let r = Resp {
audience: "https://someone-else.example".into(),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::AudienceMismatch)
));
}
#[test]
fn expired_assertion_is_rejected() {
let t = test_idp();
let now = OffsetDateTime::now_utc();
let r = Resp {
not_before: now - Duration::hours(2),
not_on_or_after: now - Duration::hours(1),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::TimeBounds)
));
}
#[test]
fn future_assertion_is_rejected() {
let t = test_idp();
let now = OffsetDateTime::now_utc();
let r = Resp {
not_before: now + Duration::hours(1),
not_on_or_after: now + Duration::hours(2),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::TimeBounds)
));
}
#[test]
fn wrong_issuer_is_rejected() {
let t = test_idp();
let r = Resp {
issuer: "https://evil-idp.example".into(),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::IssuerMismatch)
));
}
#[test]
fn non_success_status_is_rejected() {
let t = test_idp();
let r = Resp {
status: "urn:oasis:names:tc:SAML:2.0:status:Requester".into(),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::Status(_))
));
}
#[test]
fn idp_initiated_has_no_in_response_to() {
// No InResponseTo => the HTTP layer must gate it behind allow_idp_initiated.
let t = test_idp();
let r = Resp {
in_response_to: None,
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
let out = consume(&signed, t.cert_der).expect("unsolicited but otherwise valid");
assert!(out.in_response_to.is_none());
}
+84 -12
View File
@@ -1,16 +1,17 @@
//! SAML SSO configuration — FleetDM-shaped, storage-only for v0.4.5. //! SAML SSO configuration — FleetDM-shaped.
//! //!
//! The operator pastes their IdP's metadata XML (or its URL) and a //! The operator pastes their IdP's metadata XML (or its URL) and a
//! human-readable label; v0.4.5 just persists it. The actual SAML //! human-readable label. As of v0.5.1 the SAML login flow is wired
//! response-validation / JIT-provisioning flow lands in a later release //! end-to-end (see [`crate::saml`]): SP-initiated AuthnRequest, the ACS
//! — for now we cover the "configurable" half so an operator can teach //! endpoint, pure-Rust signature verification, and operator-session
//! OpenPXE about their IdP today and flip the switch on next upgrade. //! minting. This module owns only the persisted *configuration*.
//! //!
//! Shape borrowed from <https://github.com/fleetdm/fleet>'s app-config //! Shape borrowed from <https://github.com/fleetdm/fleet>'s app-config
//! SSO block, minus the user-RBAC fields (OpenPXE is single-tier: you //! SSO block, minus the user-RBAC fields (OpenPXE is single-tier: any
//! have access or you don't). Entity ID is omitted from the operator //! IdP-authenticated user the SP cryptographically verifies gets an
//! UI per the v0.4.5 brief — it defaults to the advertised public base //! operator session; there is no per-user role table). Entity ID is
//! URL when SAML wiring lands, which is what most IdPs expect anyway. //! exposed (FleetDM-style) but defaults to the advertised public base
//! URL when blank, which is what most IdPs expect anyway.
use parking_lot::RwLock; use parking_lot::RwLock;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -47,6 +48,18 @@ pub struct SsoConfig {
/// future SAML flow; not validated here beyond a basic length cap. /// future SAML flow; not validated here beyond a basic length cap.
#[serde(default)] #[serde(default)]
pub metadata_url: String, pub metadata_url: String,
/// SP Entity ID advertised to the IdP — mirrors FleetDM's "Entity ID".
/// Must exactly match the SP/Relying-Party entry configured on the IdP.
/// Empty falls back to the advertised public base URL at runtime, which
/// is what most IdPs expect. Length-capped at [`MAX_URL_LEN`].
#[serde(default)]
pub entity_id: String,
/// Allow IdP-initiated login — an unsolicited `<Response>` POSTed to the
/// ACS with no `InResponseTo`. Mirrors FleetDM's "Allow SSO login
/// initiated by identity provider". Default off; SP-initiated (the
/// "Sign in with X" button) is always allowed regardless.
#[serde(default)]
pub allow_idp_initiated: bool,
} }
impl SsoConfig { impl SsoConfig {
@@ -56,8 +69,7 @@ impl SsoConfig {
/// surface a yellow "configured but not live yet" hint. /// surface a yellow "configured but not live yet" hint.
#[must_use] #[must_use]
pub fn is_usable(&self) -> bool { pub fn is_usable(&self) -> bool {
self.enabled self.enabled && (!self.metadata.trim().is_empty() || !self.metadata_url.trim().is_empty())
&& (!self.metadata.trim().is_empty() || !self.metadata_url.trim().is_empty())
} }
} }
@@ -108,6 +120,12 @@ impl SsoStore {
cfg.idp_logo_url = cfg.idp_logo_url.trim().to_string(); cfg.idp_logo_url = cfg.idp_logo_url.trim().to_string();
cfg.metadata = cfg.metadata.trim().to_string(); cfg.metadata = cfg.metadata.trim().to_string();
cfg.metadata_url = cfg.metadata_url.trim().to_string(); cfg.metadata_url = cfg.metadata_url.trim().to_string();
cfg.entity_id = cfg.entity_id.trim().to_string();
if cfg.entity_id.len() > MAX_URL_LEN {
return Err(Error::Invalid(format!(
"entity_id exceeds {MAX_URL_LEN}-char cap"
)));
}
if cfg.metadata.len() > MAX_METADATA_BYTES { if cfg.metadata.len() > MAX_METADATA_BYTES {
return Err(Error::Invalid(format!( return Err(Error::Invalid(format!(
"metadata XML exceeds {MAX_METADATA_BYTES}-byte cap" "metadata XML exceeds {MAX_METADATA_BYTES}-byte cap"
@@ -217,6 +235,8 @@ mod tests {
metadata: String::new(), metadata: String::new(),
metadata_url: "https://idp.example.com/metadata".into(), metadata_url: "https://idp.example.com/metadata".into(),
idp_logo_url: String::new(), idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
}) })
.unwrap(); .unwrap();
drop(s); drop(s);
@@ -239,6 +259,8 @@ mod tests {
metadata: xml.into(), metadata: xml.into(),
metadata_url: String::new(), metadata_url: String::new(),
idp_logo_url: String::new(), idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
}) })
.unwrap(); .unwrap();
assert!(s.snapshot().is_usable()); assert!(s.snapshot().is_usable());
@@ -254,6 +276,8 @@ mod tests {
metadata: String::new(), metadata: String::new(),
metadata_url: String::new(), metadata_url: String::new(),
idp_logo_url: String::new(), idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
}); });
assert!(matches!(r, Err(Error::Invalid(_)))); assert!(matches!(r, Err(Error::Invalid(_))));
// …and a disabled blank config is fine. // …and a disabled blank config is fine.
@@ -270,6 +294,8 @@ mod tests {
metadata: String::new(), metadata: String::new(),
metadata_url: "ftp://idp.example.com/metadata".into(), metadata_url: "ftp://idp.example.com/metadata".into(),
idp_logo_url: String::new(), idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
}); });
assert!(matches!(r, Err(Error::Invalid(_)))); assert!(matches!(r, Err(Error::Invalid(_))));
} }
@@ -287,6 +313,8 @@ mod tests {
metadata: String::new(), metadata: String::new(),
metadata_url: String::new(), metadata_url: String::new(),
idp_logo_url: "data:image/png;base64,...".into(), idp_logo_url: "data:image/png;base64,...".into(),
entity_id: String::new(),
allow_idp_initiated: false,
}); });
assert!(matches!(r, Err(Error::Invalid(_)))); assert!(matches!(r, Err(Error::Invalid(_))));
// Real HTTPS URL is fine. // Real HTTPS URL is fine.
@@ -296,9 +324,51 @@ mod tests {
metadata: String::new(), metadata: String::new(),
metadata_url: String::new(), metadata_url: String::new(),
idp_logo_url: "https://idp.example.com/logo.png".into(), idp_logo_url: "https://idp.example.com/logo.png".into(),
entity_id: String::new(),
allow_idp_initiated: false,
}) })
.unwrap(); .unwrap();
assert_eq!(s.snapshot().idp_logo_url, "https://idp.example.com/logo.png"); assert_eq!(
s.snapshot().idp_logo_url,
"https://idp.example.com/logo.png"
);
}
#[test]
fn entity_id_and_idp_initiated_round_trip() {
// v0.5.1: SP Entity ID + IdP-initiated toggle persist across reload.
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
s.replace(SsoConfig {
enabled: true,
idp_name: "Keycloak".into(),
metadata: String::new(),
metadata_url: "https://idp.example.com/metadata".into(),
idp_logo_url: String::new(),
entity_id: "https://pxe.example.com".into(),
allow_idp_initiated: true,
})
.unwrap();
drop(s);
let cfg = SsoStore::load_or_default(dir.path()).snapshot();
assert_eq!(cfg.entity_id, "https://pxe.example.com");
assert!(cfg.allow_idp_initiated);
}
#[test]
fn entity_id_cap_enforced() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let r = s.replace(SsoConfig {
enabled: false,
idp_name: String::new(),
metadata: String::new(),
metadata_url: String::new(),
idp_logo_url: String::new(),
entity_id: "x".repeat(MAX_URL_LEN + 1),
allow_idp_initiated: false,
});
assert!(matches!(r, Err(Error::Invalid(_))));
} }
#[test] #[test]
@@ -312,6 +382,8 @@ mod tests {
metadata: oversize, metadata: oversize,
metadata_url: String::new(), metadata_url: String::new(),
idp_logo_url: String::new(), idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
}); });
assert!(matches!(r, Err(Error::Invalid(_)))); assert!(matches!(r, Err(Error::Invalid(_))));
} }
+210
View File
@@ -0,0 +1,210 @@
//! Wake-on-LAN.
//!
//! v0.5.0: from the Hosts tab, an operator can wake a bound machine.
//! WoL is a "magic packet" — six `0xFF` bytes followed by the target
//! MAC repeated sixteen times (102 bytes total) — broadcast on the
//! local segment. The NIC's WoL logic matches the repeated MAC and
//! powers the board on.
//!
//! ## Why this is trivial and safe in our container
//!
//! - It's a single UDP datagram to a broadcast address. No privileged
//! *local* port is needed (we bind an ephemeral source port); the
//! destination port is conventionally 9 (discard) or 7 (echo), and
//! nothing actually listens there — the magic is in the payload, not
//! the port. So WoL works without any extra capability.
//! - We send to the limited broadcast `255.255.255.255` (stays on the
//! local link) and, when the caller knows the server's own subnet
//! broadcast, to that too — directed broadcast reaches the right VLAN
//! even when the host bridges multiple segments.
//!
//! ## Limits
//!
//! WoL only crosses L2. If the target is on a different subnet than the
//! OpenPXE host, the intervening router must be configured to forward
//! directed broadcasts (most aren't, by design). For the common case —
//! OpenPXE and its PXE clients on the same VLAN — the limited broadcast
//! is enough.
use crate::{Error, Result};
use std::net::{Ipv4Addr, SocketAddrV4, UdpSocket};
/// Conventional WoL destination port. 9 (discard) is the de-facto
/// default; the port is immaterial since the match is on the payload.
const WOL_PORT: u16 = 9;
/// Parse a MAC string in any common form (`aa:bb:cc:dd:ee:ff`,
/// `aa-bb-...`, `aabb.ccdd.eeff`, or bare hex) into six octets.
///
/// Returns `Error::Invalid` if it doesn't resolve to exactly six bytes.
pub fn parse_mac(mac: &str) -> Result<[u8; 6]> {
// Strip every non-hex-digit, then expect exactly 12 hex chars.
let hex: String = mac.chars().filter(char::is_ascii_hexdigit).collect();
if hex.len() != 12 {
return Err(Error::Invalid(format!(
"invalid MAC '{mac}': expected 6 octets (12 hex digits), got {}",
hex.len()
)));
}
let mut out = [0u8; 6];
for (i, byte) in out.iter_mut().enumerate() {
// Each octet is two hex chars; unwrap is safe — we validated
// the length and that every char is a hex digit above.
*byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16)
.map_err(|e| Error::Invalid(format!("invalid MAC '{mac}': {e}")))?;
}
Ok(out)
}
/// Build the 102-byte magic packet for `mac`.
#[must_use]
pub fn magic_packet(mac: [u8; 6]) -> [u8; 102] {
let mut pkt = [0u8; 102];
// 6 bytes of 0xFF.
for b in &mut pkt[..6] {
*b = 0xFF;
}
// MAC repeated 16 times.
for rep in 0..16 {
let start = 6 + rep * 6;
pkt[start..start + 6].copy_from_slice(&mac);
}
pkt
}
/// Send a Wake-on-LAN magic packet for `mac` to every address in
/// `broadcasts` (e.g. `255.255.255.255` plus the server's subnet
/// broadcast). Returns the number of broadcast addresses the packet was
/// successfully sent to; errors only if the MAC is malformed or the
/// socket can't be opened at all.
pub fn wake(mac: &str, broadcasts: &[Ipv4Addr]) -> Result<usize> {
let parsed = parse_mac(mac)?;
let packet = magic_packet(parsed);
// Always include the limited broadcast even if the caller didn't —
// it's the one that works with zero network configuration.
let mut targets: Vec<Ipv4Addr> = vec![Ipv4Addr::BROADCAST];
for b in broadcasts {
if !targets.contains(b) {
targets.push(*b);
}
}
let sent = send_magic(&packet, &targets, WOL_PORT)?;
tracing::info!(
target: "openpxe::wol",
mac = %mac, broadcasts = sent,
"Wake-on-LAN magic packet sent"
);
Ok(sent)
}
/// Open a broadcast-enabled UDP socket and send `packet` to every
/// `target:port`. Returns how many sends succeeded. Errors if the
/// socket can't be opened or if *no* target accepted the packet.
fn send_magic(packet: &[u8], targets: &[Ipv4Addr], port: u16) -> Result<usize> {
// Bind an ephemeral local UDP port on all interfaces. SO_BROADCAST
// must be enabled to send to a broadcast address.
let sock = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0))
.map_err(|e| Error::Invalid(format!("could not open WoL socket: {e}")))?;
sock.set_broadcast(true)
.map_err(|e| Error::Invalid(format!("could not enable broadcast: {e}")))?;
let mut sent = 0usize;
for &addr in targets {
match sock.send_to(packet, SocketAddrV4::new(addr, port)) {
Ok(_) => sent += 1,
Err(e) => {
tracing::warn!(
target: "openpxe::wol",
broadcast = %addr,
"WoL send failed: {e}"
);
}
}
}
if sent == 0 {
return Err(Error::Invalid(
"Wake-on-LAN: no broadcast address accepted the packet".into(),
));
}
Ok(sent)
}
/// Compute the IPv4 broadcast address for `ip`/`mask`, if both parse.
/// Used so the caller can include the server's own subnet broadcast
/// alongside the limited broadcast.
#[must_use]
pub fn subnet_broadcast(ip: Ipv4Addr, mask: Ipv4Addr) -> Ipv4Addr {
let ip = u32::from(ip);
let mask = u32::from(mask);
Ipv4Addr::from(ip | !mask)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_mac_accepts_common_forms() {
let want = [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff];
assert_eq!(parse_mac("aa:bb:cc:dd:ee:ff").unwrap(), want);
assert_eq!(parse_mac("AA-BB-CC-DD-EE-FF").unwrap(), want);
assert_eq!(parse_mac("aabb.ccdd.eeff").unwrap(), want);
assert_eq!(parse_mac("aabbccddeeff").unwrap(), want);
}
#[test]
fn parse_mac_rejects_bad_length() {
assert!(parse_mac("aa:bb:cc").is_err());
assert!(parse_mac("").is_err());
assert!(parse_mac("zz:bb:cc:dd:ee:ff").is_err()); // non-hex stripped → too short
}
#[test]
fn magic_packet_shape() {
let pkt = magic_packet([0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
assert_eq!(&pkt[..6], &[0xFF; 6]);
// First MAC repetition.
assert_eq!(&pkt[6..12], &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
// Last (16th) repetition ends the packet.
assert_eq!(&pkt[96..102], &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]);
}
#[test]
fn subnet_broadcast_computes() {
assert_eq!(
subnet_broadcast(
Ipv4Addr::new(192, 168, 1, 49),
Ipv4Addr::new(255, 255, 255, 0)
),
Ipv4Addr::new(192, 168, 1, 255)
);
assert_eq!(
subnet_broadcast(
Ipv4Addr::new(10, 5, 3, 7),
Ipv4Addr::new(255, 255, 0, 0)
),
Ipv4Addr::new(10, 5, 255, 255)
);
}
#[test]
fn send_magic_delivers_intact_packet_over_loopback() {
// Deterministic round-trip that doesn't depend on the sandbox
// permitting a real L2 broadcast: bind a receiver on loopback
// and confirm send_magic transmits the exact 102-byte packet.
let rx = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).unwrap();
let port = rx.local_addr().unwrap().port();
rx.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap();
let packet = magic_packet([0x0a, 0x1b, 0x2c, 0x3d, 0x4e, 0x5f]);
let sent = send_magic(&packet, &[Ipv4Addr::LOCALHOST], port).unwrap();
assert_eq!(sent, 1);
let mut buf = [0u8; 128];
let n = rx.recv(&mut buf).unwrap();
assert_eq!(n, 102, "magic packet should be 102 bytes");
assert_eq!(&buf[..102], &packet[..]);
}
}
+23 -2
View File
@@ -4,6 +4,10 @@ version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
# v0.5.0: inherit the workspace repository so CARGO_PKG_REPOSITORY is
# populated at build time — the About-tab update check derives the
# Gitea releases API URL from it.
repository.workspace = true
description = "HTTP server: ISO uploads, iPXE script generation, ISO streaming" description = "HTTP server: ISO uploads, iPXE script generation, ISO streaming"
[lints] [lints]
@@ -29,11 +33,17 @@ 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
# v0.5.0: outbound HTTP for chat webhooks (Slack/Teams/Discord) and the
# About-tab "check for updates" call to the Gitea releases API; SMTP for
# email notifications. Both use rustls so the static musl binary stays
# OpenSSL-free.
reqwest.workspace = true
lettre.workspace = true
# v0.5.1: decode the base64 SAMLResponse at the ACS endpoint.
base64.workspace = true
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] } tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] }
@@ -44,3 +54,14 @@ time = { workspace = true }
# v0.4.61: integration tests need to generate real PNG bytes for the # v0.4.61: integration tests need to generate real PNG bytes for the
# `/branding/pxe-logo` compositor; hand-rolled CRCs are too fragile. # `/branding/pxe-logo` compositor; hand-rolled CRCs are too fragile.
image = { version = "0.25", default-features = false, features = ["png"] } image = { version = "0.25", default-features = false, features = ["png"] }
# v0.5.1: the SAML ACS integration tests mint a throwaway IdP keypair
# (rcgen) and sign a SAMLResponse with bergshamra so the happy-path,
# replay, and IdP-initiated-gating flows exercise real signatures.
rcgen = "0.13"
bergshamra = { workspace = true }
# v0.5.4: snapshot the generated iPXE menu so any unintended drift (a
# dropped line, reordered item) is caught and reviewed, not silently shipped.
insta = "1.40"
# v0.5.4: stand up a mock HTTP server to exercise the SAML metadata-URL
# fetch path (previously untested because it did a real network GET).
wiremock = "0.6"
+1290 -171
View File
File diff suppressed because it is too large Load Diff
+47 -16
View File
@@ -140,9 +140,16 @@ fn cookie_attrs(value: &str, max_age: Option<i64>) -> String {
// never overflow i64, but clippy's `cast_possible_wrap` lint wants // never overflow i64, but clippy's `cast_possible_wrap` lint wants
// us to be explicit. `cast_signed` is the documented form. // us to be explicit. `cast_signed` is the documented form.
let lifetime = max_age.unwrap_or_else(|| SESSION_TTL.as_secs().cast_signed()); let lifetime = max_age.unwrap_or_else(|| SESSION_TTL.as_secs().cast_signed());
format!( format!("{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={lifetime}")
"{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={lifetime}" }
)
/// Build the `Set-Cookie` header value that establishes a fresh operator
/// session with the default 24h TTL. Exposed so the SAML ACS handler can
/// attach an operator session to its post-login redirect, exactly as the
/// Forms-login path does via [`login_response`].
#[must_use]
pub fn session_cookie(session: &str) -> String {
cookie_attrs(session, None)
} }
fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> { fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
@@ -169,9 +176,18 @@ fn is_public_path(path: &str) -> bool {
return true; return true;
} }
// Auth surface and iPXE long-poll endpoints (no cookie available). // Auth surface and iPXE long-poll endpoints (no cookie available).
// The SAML SP endpoints are pre-auth by nature — the operator hasn't a
// session yet when they start (or arrive from) the IdP. `/api/sso`
// (the config GET/PUT, no trailing slash) stays gated.
matches!( matches!(
path, path,
"/api/setup" | "/api/login" | "/api/logout" | "/api/me" "/api/setup"
| "/api/login"
| "/api/logout"
| "/api/me"
| "/api/sso/login"
| "/api/sso/acs"
| "/api/sso/metadata"
) || path.starts_with("/api/queue/join") ) || path.starts_with("/api/queue/join")
|| path.starts_with("/api/queue/poll/") || path.starts_with("/api/queue/poll/")
} }
@@ -222,10 +238,7 @@ pub struct SetupBody {
/// guards against a leaked WebUI being re-bootstrapped by an attacker /// guards against a leaked WebUI being re-bootstrapped by an attacker
/// who's seen the deployment URL. After bootstrap, the new session /// who's seen the deployment URL. After bootstrap, the new session
/// cookie is set so the operator goes straight to the dashboard. /// cookie is set so the operator goes straight to the dashboard.
pub async fn api_setup( pub async fn api_setup(State(state): State<AppState>, Json(body): Json<SetupBody>) -> Response {
State(state): State<AppState>,
Json(body): Json<SetupBody>,
) -> Response {
if state.admin.is_configured() { if state.admin.is_configured() {
return ( return (
StatusCode::CONFLICT, StatusCode::CONFLICT,
@@ -280,10 +293,7 @@ pub async fn api_login(State(state): State<AppState>, Json(body): Json<LoginBody
login_response(StatusCode::OK, &pub_, &session) login_response(StatusCode::OK, &pub_, &session)
} }
pub async fn api_logout( pub async fn api_logout(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
State(state): State<AppState>,
headers: axum::http::HeaderMap,
) -> Response {
if let Some(t) = parse_cookie(&headers) { if let Some(t) = parse_cookie(&headers) {
state.sessions.revoke(&t); state.sessions.revoke(&t);
} }
@@ -303,12 +313,20 @@ pub async fn api_logout(
/// * `authenticated: false` — admin exists, no session; show login. /// * `authenticated: false` — admin exists, no session; show login.
/// * `authenticated: true` + `user` — let the dashboard load. /// * `authenticated: true` + `user` — let the dashboard load.
pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response { pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
// v0.5.0: include branding bootstrap so the pre-auth login/setup
// screens can render the FleetDM-style full-width custom logo (and
// cache-bust it) without an extra round trip. `/api/me` is public,
// and the logo asset is public, so this leaks nothing sensitive.
let has_custom_logo = state.branding.has_any_web_logo();
let logo_rev = state.branding.logo_rev();
if !state.admin.is_configured() { if !state.admin.is_configured() {
return ( return (
StatusCode::OK, StatusCode::OK,
Json(json!({ Json(json!({
"setup_required": true, "setup_required": true,
"authenticated": false, "authenticated": false,
"has_custom_logo": has_custom_logo,
"logo_rev": logo_rev,
})), })),
) )
.into_response(); .into_response();
@@ -323,6 +341,8 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
"authenticated": true, "authenticated": true,
"user": state.admin.snapshot(), "user": state.admin.snapshot(),
"session_user": u, "session_user": u,
"has_custom_logo": has_custom_logo,
"logo_rev": logo_rev,
})), })),
) )
.into_response(), .into_response(),
@@ -331,6 +351,8 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
Json(json!({ Json(json!({
"setup_required": false, "setup_required": false,
"authenticated": false, "authenticated": false,
"has_custom_logo": has_custom_logo,
"logo_rev": logo_rev,
})), })),
) )
.into_response(), .into_response(),
@@ -437,8 +459,14 @@ mod tests {
fn public_path_allowlist() { fn public_path_allowlist() {
// PXE + chrome paths bypass auth. // PXE + chrome paths bypass auth.
for p in [ for p in [
"/", "/assets/app.js", "/boot.ipxe", "/boot/fake.ipxe", "/",
"/iso/fake.iso", "/ipxe/snponly.efi", "/healthz", "/readyz", "/assets/app.js",
"/boot.ipxe",
"/boot/fake.ipxe",
"/iso/fake.iso",
"/ipxe/snponly.efi",
"/healthz",
"/readyz",
"/metrics", "/metrics",
// v0.4.6: iPXE fetches this for `console --picture` before // v0.4.6: iPXE fetches this for `console --picture` before
// it can possibly have a session cookie. // it can possibly have a session cookie.
@@ -450,6 +478,10 @@ mod tests {
for p in ["/api/setup", "/api/login", "/api/logout", "/api/me"] { for p in ["/api/setup", "/api/login", "/api/logout", "/api/me"] {
assert!(is_public_path(p), "expected {p} to be public"); assert!(is_public_path(p), "expected {p} to be public");
} }
// v0.5.1: SAML SP endpoints are pre-auth (no session yet).
for p in ["/api/sso/login", "/api/sso/acs", "/api/sso/metadata"] {
assert!(is_public_path(p), "expected {p} to be public");
}
// iPXE long-poll endpoints are public (no cookie available). // iPXE long-poll endpoints are public (no cookie available).
assert!(is_public_path("/api/queue/join")); assert!(is_public_path("/api/queue/join"));
assert!(is_public_path("/api/queue/poll/abc")); assert!(is_public_path("/api/queue/poll/abc"));
@@ -471,8 +503,7 @@ mod tests {
let mut h = axum::http::HeaderMap::new(); let mut h = axum::http::HeaderMap::new();
h.insert( h.insert(
header::COOKIE, header::COOKIE,
HeaderValue::from_str(&format!("foo=bar; {SESSION_COOKIE}=abc123; baz=qux")) HeaderValue::from_str(&format!("foo=bar; {SESSION_COOKIE}=abc123; baz=qux")).unwrap(),
.unwrap(),
); );
assert_eq!(parse_cookie(&h).as_deref(), Some("abc123")); assert_eq!(parse_cookie(&h).as_deref(), Some("abc123"));
// Different name → None. // Different name → None.
+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
);
}
}
+85 -43
View File
@@ -30,14 +30,15 @@ use std::fmt::Write as _;
/// Top-level OpenPXE boot menu. Serialized identically for BIOS and UEFI /// Top-level OpenPXE boot menu. Serialized identically for BIOS and UEFI
/// clients because iPXE normalises the menu primitives across firmwares. /// clients because iPXE normalises the menu primitives across firmwares.
/// ///
/// v0.4.6: rendered with an iVentoy-style polished frame — centered /// v0.4.69: rendered with an iVentoy-style graphical frame — a
/// OpenPXE wordmark banner at the top (ASCII so every iPXE build can /// `console --picture` directive paints a full-screen PNG background
/// paint it), a footer carrying version + arch + firmware kind, and an /// (the operator's uploaded logo on a dark field, or the default
/// optional `console --picture` directive that paints the operator's /// OpenPXE mark) with the menu text overlaid below a reserved top
/// uploaded raster logo on top when the iPXE binary on the wire was /// margin, plus a footer carrying version + arch + firmware kind. On
/// built with PNG support. The ASCII banner is always rendered so /// iPXE binaries built with `IMAGE_PNG` + `CONSOLE_FRAMEBUFFER` (our
/// even when the picture call no-ops the screen still reads as /// x86_64 UEFI binaries, compiled from source) the background paints;
/// "OpenPXE — here is the menu" rather than a featureless box. /// on binaries without PNG support the `|| console` fallback yields a
/// clean text menu. The old ASCII wordmark has been removed.
#[must_use] #[must_use]
pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String { pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String {
let mut s = String::new(); let mut s = String::new();
@@ -56,14 +57,28 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
let _ = writeln!(s, "set base-url {base}"); let _ = writeln!(s, "set base-url {base}");
let _ = writeln!(s, "set esc:hex 1b"); let _ = writeln!(s, "set esc:hex 1b");
let _ = writeln!(s, "set cls ${{esc:string}}[2J"); let _ = writeln!(s, "set cls ${{esc:string}}[2J");
// v0.4.6: best-effort graphics console with the operator-uploaded // v0.4.69: graphical background. `/branding/pxe-logo` always
// raster logo. Falls back to plain text console on iPXE builds // returns a full-screen 1024×768 PNG now — the operator's logo on a
// without PNG support — the `||` chain keeps a parse-clean // dark field, or a default OpenPXE mark when none is uploaded. The
// single-statement form so even the strictest iPXE parsers accept // `--top 290` reserves the top band (where the logo paints) so the
// it. The `console` reset at the end re-syncs the menu output. // menu text lands below it.
//
// v0.5.7: gate the whole command behind `iseq ${platform} efi`.
// `console --picture` needs IMAGE_PNG + CONSOLE_FRAMEBUFFER, which
// only our from-source UEFI binaries carry (x86_64/arm64 UEFI — see
// deploy/docker/Dockerfile). The fetched BIOS `undionly.kpxe` has
// neither, and on legacy BIOS the `--picture` attempt misbehaves
// *before* the trailing `|| console` fallback can recover (it tries
// to set a framebuffer mode the BIOS console can't honour). Guarding
// on platform means BIOS clients never issue the command at all —
// they drop straight to the plain text menu — while UEFI clients
// still get the graphical background. A PNG-less UEFI build (e.g. the
// upstream i386-efi baseline) still falls back gracefully through the
// same `|| console`. No operator toggle needed; mixed BIOS+UEFI
// fleets each get the right treatment automatically.
let _ = writeln!( let _ = writeln!(
s, s,
"console --picture {base}/branding/pxe-logo || 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").
@@ -77,19 +92,8 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
); );
let _ = writeln!(s, ":menu"); let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu OpenPXE - network boot menu"); let _ = writeln!(s, "menu OpenPXE - network boot menu");
// ASCII OpenPXE wordmark. Works on every iPXE build, including // v0.4.69: the ASCII wordmark is gone — the graphical background
// the boot.ipxe.org pre-builds we ship (which omit `IMAGE_PNG`, // (set via `console --picture` above) carries the branding now.
// so `console --picture` paints nothing). When the queued iPXE
// source-build lands and the operator's uploaded raster actually
// paints via `console --picture`, this banner can be retired in
// favour of the real image. The compositor at
// /branding/pxe-logo is already wired and waiting.
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --gap -- ___ ___ __ __ ___");
let _ = writeln!(s, "item --gap -- / _ \\ _ __ ___ _ _ | _ \\ \\/ / | __|");
let _ = writeln!(s, "item --gap -- | (_) | '_ \\/ -_) ' \\ | _/ \\ / | _|");
let _ = writeln!(s, "item --gap -- \\___/| .__/\\___|_||_| |_| /_/\\_\\ |___|");
let _ = writeln!(s, "item --gap -- |_|");
let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --gap");
let _ = writeln!( let _ = writeln!(
s, s,
@@ -105,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,
@@ -464,8 +469,21 @@ pub fn render_queue_entry(base_url: &str) -> String {
} }
/// Per-entry boot script (same as Phase 1, with extra_kernel_args appended). /// Per-entry boot script (same as Phase 1, with extra_kernel_args appended).
///
/// `unattended_args` (v0.5.2) carries the per-host unattended-install
/// kernel arguments (`inst.ks=…`, `auto=true … url=…`, or
/// `autoinstall ds=nocloud-net;s=…`) when the requesting MAC has a
/// deployment profile with an answer file selected. It's appended to the
/// Linux kernel command line after the operator's global extra args, and
/// ignored for Windows (wimboot) / sanboot entries which don't take a
/// kernel cmdline.
#[must_use] #[must_use]
pub fn render_entry(entry: &BootEntry, settings: &Settings, base_url: &str) -> String { pub fn render_entry(
entry: &BootEntry,
settings: &Settings,
base_url: &str,
unattended_args: Option<&str>,
) -> String {
let mut s = String::new(); let mut s = String::new();
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
@@ -481,6 +499,12 @@ pub fn render_entry(entry: &BootEntry, settings: &Settings, base_url: &str) -> S
cmdline.push(' '); cmdline.push(' ');
cmdline.push_str(settings.extra_kernel_args.trim()); cmdline.push_str(settings.extra_kernel_args.trim());
} }
if let Some(extra) = unattended_args {
if !extra.trim().is_empty() {
cmdline.push(' ');
cmdline.push_str(extra.trim());
}
}
let _ = writeln!(s, "kernel {base}/{kernel_url} {cmdline}"); let _ = writeln!(s, "kernel {base}/{kernel_url} {cmdline}");
for u in initrd_urls { for u in initrd_urls {
let _ = writeln!(s, "initrd {base}/{u}"); let _ = writeln!(s, "initrd {base}/{u}");
@@ -631,27 +655,29 @@ mod password_tests {
#[test] #[test]
fn top_menu_has_polished_branding_and_arch_footer() { fn top_menu_has_polished_branding_and_arch_footer() {
// v0.4.6 polish + v0.4.62 stability fixes: the menu emits a // v0.4.69: the menu emits a `console --picture` line that paints
// `console --picture` line that PNG-capable iPXE builds will // a full-screen PNG background (the operator's logo, or the
// honour (queued for a follow-up release once we can rebuild // default OpenPXE mark) reserving a top margin for it, then
// iPXE from source on native x86_64 hardware), an ASCII // falls back to a clean text console on iPXE builds without PNG
// OpenPXE wordmark that works on every iPXE build (including // support. The ASCII wordmark is gone — the graphical
// the boot.ipxe.org pre-builds we currently ship), and a // background carries the branding now. A single-line footer
// single-line footer carrying the OpenPXE version + arch. // still carries the OpenPXE version + arch.
let settings = Settings::default(); let settings = Settings::default();
let s = render_menu(&[], &settings, "http://10.0.0.5"); let s = render_menu(&[], &settings, "http://10.0.0.5");
assert!( assert!(
s.contains("console --picture http://10.0.0.5/branding/pxe-logo"), s.contains("console --picture http://10.0.0.5/branding/pxe-logo"),
"missing console --picture line:\n{s}" "missing console --picture line:\n{s}"
); );
// The picture call reserves a top margin for the logo band.
assert!(s.contains("--top 290"), "missing --top margin:\n{s}");
// Picture-or-text-console must be a single statement so older // Picture-or-text-console must be a single statement so older
// iPXE parsers don't choke on the chain. // iPXE parsers don't choke on the chain.
assert!(s.contains("|| console"), "missing graceful fallback:\n{s}"); assert!(s.contains("|| console"), "missing graceful fallback:\n{s}");
// ASCII wordmark — paints on every iPXE build regardless of // The ASCII wordmark must be GONE — its removal is the whole
// PNG support. // point of v0.4.69's graphical background.
assert!( assert!(
s.contains("___ ___ __ __ ___"), !s.contains("___ ___ __ __ ___"),
"ASCII banner missing first row:\n{s}" "ASCII banner should have been removed:\n{s}"
); );
// Footer with version + arch interpolation. The version comes // Footer with version + arch interpolation. The version comes
// from CARGO_PKG_VERSION at compile time. // from CARGO_PKG_VERSION at compile time.
@@ -676,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();
+3
View File
@@ -15,9 +15,12 @@
pub mod app; pub mod app;
pub mod auth; pub mod auth;
pub mod error;
pub mod ipxe_script; pub mod ipxe_script;
pub mod iso_fs; pub mod iso_fs;
pub mod log_stream; pub mod log_stream;
pub mod notify;
pub mod saml_routes;
pub mod state; pub mod state;
pub mod terminal; pub mod terminal;
pub mod uploads; pub mod uploads;
+138
View File
@@ -0,0 +1,138 @@
//! Notification *delivery* — the network half of the notify feature.
//!
//! `openpxe_core::notify` owns the config + persistence; this module
//! turns a `NotifyConfig` + a message into an actual delivery:
//!
//! - Slack / Discord / Teams → HTTP POST of a provider-shaped JSON
//! body to the operator's incoming-webhook URL (via `reqwest`).
//! - SMTP → a TLS email via `lettre`.
//!
//! Every send is best-effort and time-bounded: a flaky webhook must
//! never wedge a PXE boot. Callers fire these from a detached task.
use openpxe_core::{NotifyConfig, NotifyKind};
use std::time::Duration;
/// Hard ceiling on any single delivery so a hung endpoint can't pin a
/// task forever.
const SEND_TIMEOUT: Duration = Duration::from_secs(10);
/// Deliver `body` (with an optional `subject`, used as the email
/// subject / chat bold-line) using the active provider in `cfg`.
/// Returns `Ok(())` on success, or a human-readable error suitable for
/// surfacing in the "Send test" response.
pub async fn send(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(), String> {
if !cfg.is_usable() {
return Err("notifications are not enabled / fully configured".into());
}
match cfg.kind {
NotifyKind::Slack | NotifyKind::Discord | NotifyKind::Teams => {
send_webhook(cfg, subject, body).await
}
NotifyKind::Smtp => send_email(cfg, subject, body).await,
}
}
async fn send_webhook(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(), String> {
// Each chat platform wants a different JSON shape for an incoming
// webhook. Keep the bodies minimal and plain-text-ish so they
// render cleanly everywhere.
let combined = if subject.is_empty() {
body.to_string()
} else {
format!("*{subject}*\n{body}")
};
let payload = match cfg.kind {
NotifyKind::Slack => serde_json::json!({ "text": combined }),
NotifyKind::Discord => serde_json::json!({ "content": combined }),
NotifyKind::Teams => serde_json::json!({
// Legacy MessageCard — the format every Teams "Incoming
// Webhook" connector still accepts.
"@type": "MessageCard",
"@context": "https://schema.org/extensions",
"summary": if subject.is_empty() { "OpenPXE" } else { subject },
"title": subject,
"text": body,
}),
NotifyKind::Smtp => unreachable!("smtp handled separately"),
};
let client = reqwest::Client::builder()
.timeout(SEND_TIMEOUT)
.build()
.map_err(|e| format!("could not build HTTP client: {e}"))?;
let resp = client
.post(&cfg.webhook_url)
.json(&payload)
.send()
.await
.map_err(|e| format!("webhook POST failed: {e}"))?;
let status = resp.status();
if status.is_success() {
Ok(())
} else {
let snippet = resp
.text()
.await
.unwrap_or_default()
.chars()
.take(200)
.collect::<String>();
Err(format!("webhook returned HTTP {status}: {snippet}"))
}
}
async fn send_email(cfg: &NotifyConfig, subject: &str, body: &str) -> Result<(), String> {
use lettre::transport::smtp::authentication::Credentials;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
let from = if cfg.smtp_from.trim().is_empty() {
cfg.smtp_username.trim()
} else {
cfg.smtp_from.trim()
};
if from.is_empty() {
return Err("SMTP requires a From address (or a username to fall back to)".into());
}
let email = Message::builder()
.from(
from.parse()
.map_err(|e| format!("invalid From address '{from}': {e}"))?,
)
.to(cfg
.smtp_to
.trim()
.parse()
.map_err(|e| format!("invalid To address '{}': {e}", cfg.smtp_to))?)
.subject(if subject.is_empty() { "OpenPXE" } else { subject })
.body(body.to_string())
.map_err(|e| format!("could not build email: {e}"))?;
// Implicit TLS (465) vs STARTTLS (587). We never send plaintext.
let mut builder = if cfg.smtp_implicit_tls {
AsyncSmtpTransport::<Tokio1Executor>::relay(&cfg.smtp_host)
.map_err(|e| format!("SMTP relay setup failed: {e}"))?
} else {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.smtp_host)
.map_err(|e| format!("SMTP STARTTLS setup failed: {e}"))?
}
.port(cfg.smtp_port)
.timeout(Some(SEND_TIMEOUT));
// Auth is optional — some internal relays accept unauthenticated
// mail from trusted hosts. Only attach credentials when a username
// is set.
if !cfg.smtp_username.trim().is_empty() {
builder = builder.credentials(Credentials::new(
cfg.smtp_username.trim().to_string(),
cfg.smtp_password.clone(),
));
}
let mailer = builder.build();
mailer
.send(email)
.await
.map(|_| ())
.map_err(|e| format!("SMTP send failed: {e}"))
}
+370
View File
@@ -0,0 +1,370 @@
//! SAML 2.0 Service Provider HTTP endpoints (v0.5.1).
//!
//! * `GET /api/sso/login` — SP-initiated: build an AuthnRequest, record its
//! ID, and 302 the browser to the IdP.
//! * `POST /api/sso/acs` — Assertion Consumer Service: verify + validate
//! the IdP's `SAMLResponse`, perform the stateful checks (InResponseTo
//! correlation, IdP-initiated gating, assertion replay), mint an operator
//! session, and 302 to the dashboard. (Mirrors FleetDM's `/sso/callback`.)
//! * `GET /api/sso/metadata` — serve our SP metadata XML for IdP import.
//!
//! Stateless crypto + semantic validation live in `openpxe_core::saml`; this
//! module owns only the HTTP glue and the in-memory state the SP needs.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration as StdDuration, Instant};
use axum::{
body::Body,
extract::{Form, Query, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
};
use base64::Engine;
use parking_lot::Mutex;
use serde::Deserialize;
use time::{Duration, OffsetDateTime};
use openpxe_core::saml::{self, metadata::IdpMetadata, SamlError, SpParams};
use openpxe_core::SsoConfig;
use crate::auth;
use crate::state::AppState;
/// Outstanding AuthnRequest IDs live at most this long before a matching
/// response is considered stale (covers a slow human at the IdP login form).
const REQUEST_TTL: StdDuration = StdDuration::from_mins(10);
/// How long we fetch-cache IdP metadata loaded from a URL.
const METADATA_FETCH_TIMEOUT: StdDuration = StdDuration::from_secs(10);
/// In-memory SAML runtime state. Cheap to clone (Arc-shared).
#[derive(Clone, Default)]
pub struct SamlRuntime {
/// request_id → issued_at. Correlates a response's `InResponseTo` to a
/// request *we* actually sent (replay / CSRF defense for SP-initiated).
outstanding: Arc<Mutex<HashMap<String, Instant>>>,
/// assertion_id → expiry. A consumed assertion may not be replayed.
consumed: Arc<Mutex<HashMap<String, Instant>>>,
/// Cache of IdP metadata fetched from a URL: (url, parsed).
metadata_cache: Arc<Mutex<Option<(String, IdpMetadata)>>>,
}
impl SamlRuntime {
/// Record an AuthnRequest we just sent.
pub fn register_request(&self, id: &str) {
let mut g = self.outstanding.lock();
prune(&mut g);
g.insert(id.to_owned(), Instant::now());
}
/// Consume an outstanding request ID, returning `true` if it was present
/// and still fresh. A miss means the response doesn't correlate to any
/// live request we issued.
pub fn take_request(&self, id: &str) -> bool {
let mut g = self.outstanding.lock();
prune(&mut g);
g.remove(id).is_some()
}
/// Record a consumed assertion. Returns `false` if it was already
/// consumed (a replay) — in which case the caller must reject.
pub fn record_assertion(&self, id: &str, expiry: OffsetDateTime) -> bool {
let mut g = self.consumed.lock();
prune(&mut g);
if g.contains_key(id) {
return false;
}
let ttl = (expiry - OffsetDateTime::now_utc())
.max(Duration::ZERO)
.unsigned_abs();
g.insert(id.to_owned(), Instant::now() + ttl);
true
}
fn cached_metadata(&self, url: &str) -> Option<IdpMetadata> {
let g = self.metadata_cache.lock();
match &*g {
Some((cached_url, md)) if cached_url == url => Some(md.clone()),
_ => None,
}
}
fn cache_metadata(&self, url: String, md: IdpMetadata) {
*self.metadata_cache.lock() = Some((url, md));
}
}
/// Drop expired entries so neither map grows unbounded.
fn prune(map: &mut HashMap<String, Instant>) {
let now = Instant::now();
// For the request map this over-prunes (entries store issued_at, not
// expiry), so cap by REQUEST_TTL; the consumed map stores absolute
// expiry instants. Using saturating logic keeps both correct: request
// entries older than REQUEST_TTL go, consumed entries past expiry go.
map.retain(|_, &mut t| now.saturating_duration_since(t) < REQUEST_TTL || t > now);
}
// ─── GET /api/sso/login ───────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct LoginQuery {
/// Optional local path to return to after login (becomes RelayState).
#[serde(default)]
pub next: Option<String>,
}
pub async fn sso_login(State(state): State<AppState>, Query(q): Query<LoginQuery>) -> Response {
let cfg = state.sso.snapshot();
if !cfg.is_usable() {
return redirect("/?sso_error=unavailable");
}
let idp = match resolve_idp_metadata(&state, &cfg).await {
Ok(m) => m,
Err(e) => {
tracing::warn!(target: "openpxe::saml", "sso_login: metadata unavailable: {e}");
return redirect("/?sso_error=metadata");
}
};
let Some(dest) = idp.sso_destination().map(str::to_owned) else {
tracing::warn!(target: "openpxe::saml", "sso_login: IdP metadata has no SSO endpoint");
return redirect("/?sso_error=metadata");
};
let sp = sp_params(&state, &cfg);
let relay = safe_local_path(q.next.as_deref());
match saml::authn_request::build(&sp, &dest, Some(&relay)) {
Ok(req) => {
state.saml.register_request(&req.id);
redirect(&req.location)
}
Err(e) => {
tracing::warn!(target: "openpxe::saml", "sso_login: build AuthnRequest failed: {e}");
redirect("/?sso_error=request")
}
}
}
// ─── POST /api/sso/acs ──────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct AcsForm {
#[serde(rename = "SAMLResponse")]
pub saml_response: String,
#[serde(rename = "RelayState", default)]
pub relay_state: Option<String>,
}
pub async fn sso_acs(State(state): State<AppState>, Form(form): Form<AcsForm>) -> Response {
let cfg = state.sso.snapshot();
if !cfg.is_usable() {
return redirect("/?sso_error=unavailable");
}
let xml = match base64::engine::general_purpose::STANDARD.decode(form.saml_response.as_bytes())
{
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
Err(e) => {
tracing::warn!(target: "openpxe::saml", "acs: base64 decode failed: {e}");
return redirect("/?sso_error=1");
}
};
let idp = match resolve_idp_metadata(&state, &cfg).await {
Ok(m) => m,
Err(e) => {
tracing::warn!(target: "openpxe::saml", "acs: metadata unavailable: {e}");
return redirect("/?sso_error=metadata");
}
};
let sp = sp_params(&state, &cfg);
// Signature verification + semantic checks are CPU-bound — keep them off
// the async executor.
let now = OffsetDateTime::now_utc();
let skew = Duration::seconds(saml::DEFAULT_CLOCK_SKEW_SECS);
let verify = {
let xml = xml.clone();
let sp = sp.clone();
tokio::task::spawn_blocking(move || saml::response::consume(&xml, &sp, &idp, now, skew))
.await
};
let verified = match verify {
Ok(Ok(v)) => v,
Ok(Err(e)) => {
// Never leak which specific check failed to the browser.
tracing::warn!(target: "openpxe::saml", "acs: response rejected: {e}");
return redirect("/?sso_error=1");
}
Err(join) => {
tracing::error!(target: "openpxe::saml", "acs: verify task panicked: {join}");
return redirect("/?sso_error=1");
}
};
// Stateful checks the core deliberately left to us.
match &verified.in_response_to {
Some(id) => {
if !state.saml.take_request(id) {
tracing::warn!(target: "openpxe::saml", "acs: InResponseTo matches no live request");
return redirect("/?sso_error=1");
}
}
None => {
if !cfg.allow_idp_initiated {
tracing::warn!(target: "openpxe::saml", "acs: IdP-initiated login is disabled");
return redirect("/?sso_error=idp_initiated");
}
}
}
if !state
.saml
.record_assertion(&verified.assertion_id, verified.assertion_expiry)
{
tracing::warn!(target: "openpxe::saml", "acs: assertion replay rejected");
return redirect("/?sso_error=1");
}
// Success → mint an operator session keyed to the verified email.
let session = state.sessions.create(&verified.principal.email);
tracing::info!(
target: "openpxe::saml",
email = %verified.principal.email,
idp_initiated = verified.in_response_to.is_none(),
"SAML SSO sign-in"
);
// safe_local_path already maps None / unsafe values to "/".
let relay = safe_local_path(form.relay_state.as_deref());
redirect_with_session(&relay, &session)
}
// ─── GET /api/sso/metadata ──────────────────────────────────────────────────
pub async fn sso_metadata(State(state): State<AppState>) -> Response {
let cfg = state.sso.snapshot();
let sp = sp_params(&state, &cfg);
let xml = saml::metadata::build_sp_metadata(&sp);
(
StatusCode::OK,
[(header::CONTENT_TYPE, "application/samlmetadata+xml")],
xml,
)
.into_response()
}
// ─── helpers ────────────────────────────────────────────────────────────────
/// Derive runtime SP parameters from config + the advertised public base URL.
fn sp_params(state: &AppState, cfg: &SsoConfig) -> SpParams {
let base = state.public_base_url.trim_end_matches('/');
let entity_id = if cfg.entity_id.trim().is_empty() {
base.to_owned()
} else {
cfg.entity_id.trim().to_owned()
};
SpParams {
entity_id,
acs_url: format!("{base}/api/sso/acs"),
}
}
/// Resolve the IdP metadata: prefer the metadata URL (fetched + cached) per
/// the "URL wins" rule, else parse the pasted XML.
async fn resolve_idp_metadata(state: &AppState, cfg: &SsoConfig) -> Result<IdpMetadata, SamlError> {
let url = cfg.metadata_url.trim();
if !url.is_empty() {
if let Some(md) = state.saml.cached_metadata(url) {
return Ok(md);
}
let body = fetch_metadata(url).await?;
let md = IdpMetadata::parse(&body)?;
state.saml.cache_metadata(url.to_owned(), md.clone());
return Ok(md);
}
if !cfg.metadata.trim().is_empty() {
return IdpMetadata::parse(&cfg.metadata);
}
Err(SamlError::Metadata("no metadata source configured".into()))
}
async fn fetch_metadata(url: &str) -> Result<String, SamlError> {
let client = reqwest::Client::builder()
.timeout(METADATA_FETCH_TIMEOUT)
.build()
.map_err(|e| SamlError::Metadata(format!("http client: {e}")))?;
let resp = client
.get(url)
.send()
.await
.map_err(|e| SamlError::Metadata(format!("fetch {url}: {e}")))?;
if !resp.status().is_success() {
return Err(SamlError::Metadata(format!(
"fetch {url}: HTTP {}",
resp.status()
)));
}
resp.text()
.await
.map_err(|e| SamlError::Metadata(format!("read {url}: {e}")))
}
/// Only permit a same-site path (single leading slash) as a redirect target —
/// blocks open-redirect / protocol-relative (`//evil.com`) abuse of RelayState.
fn safe_local_path(p: Option<&str>) -> String {
match p {
Some(p) if p.starts_with('/') && !p.starts_with("//") => p.to_owned(),
_ => "/".to_owned(),
}
}
fn redirect(location: &str) -> Response {
Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, location)
.body(Body::empty())
.map_or_else(
|_| StatusCode::INTERNAL_SERVER_ERROR.into_response(),
IntoResponse::into_response,
)
}
fn redirect_with_session(location: &str, session: &str) -> Response {
Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, location)
.header(header::SET_COOKIE, auth::session_cookie(session))
.body(Body::empty())
.map_or_else(
|_| StatusCode::INTERNAL_SERVER_ERROR.into_response(),
IntoResponse::into_response,
)
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
// v0.5.4: exercise the SAML metadata-URL fetch against a mock server —
// previously this path did a real network GET and had no coverage.
#[tokio::test]
async fn fetch_metadata_returns_body_on_200() {
let server = MockServer::start().await;
let xml = "<EntityDescriptor>idp</EntityDescriptor>";
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_string(xml))
.mount(&server)
.await;
let got = fetch_metadata(&server.uri()).await.expect("fetch ok");
assert_eq!(got, xml);
}
#[tokio::test]
async fn fetch_metadata_errors_on_non_2xx() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let err = fetch_metadata(&server.uri()).await.unwrap_err();
assert!(matches!(err, SamlError::Metadata(_)), "got {err:?}");
}
}
@@ -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
+42 -10
View File
@@ -1,10 +1,13 @@
use crate::uploads::UploadSessions;
use crate::auth::SessionStore; use crate::auth::SessionStore;
use crate::saml_routes::SamlRuntime;
use crate::uploads::UploadSessions;
use openpxe_core::{ use openpxe_core::{
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus, AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
Metrics, SettingsStore, SsoStore, Metrics, NotifyStore, SettingsStore, SsoStore,
};
use openpxe_iso_store::{
IsoStore, NfsShareManager, SftpShareManager, SmbManager, SmbShareManager, UnattendedStore,
}; };
use openpxe_iso_store::{IsoStore, NfsManager, SmbManager};
use std::sync::Arc; use std::sync::Arc;
use time::OffsetDateTime; use time::OffsetDateTime;
@@ -34,9 +37,17 @@ pub struct AppState {
/// process restart (sessions are tied to UI state, not persisted — /// process restart (sessions are tied to UI state, not persisted —
/// matches Sonarr/Radarr behaviour). /// matches Sonarr/Radarr behaviour).
pub sessions: SessionStore, pub sessions: SessionStore,
/// SAML SSO configuration. v0.4.5 stores it; the actual SSO login /// SAML SSO configuration (persisted IdP metadata, Entity ID, toggles).
/// flow ships in a later release.
pub sso: SsoStore, pub sso: SsoStore,
/// v0.5.1: in-memory SAML runtime state — outstanding AuthnRequest IDs
/// (for InResponseTo correlation), consumed-assertion replay guard, and
/// a cache of fetched IdP metadata. Tied to process lifetime, like
/// `sessions`; a restart simply invalidates any in-flight SSO login.
pub saml: SamlRuntime,
/// v0.5.0: webhook / email notification config (Slack/Teams/Discord/
/// SMTP). Drives the fire-and-forget pings on boot events and powers
/// the Advanced tab's config + "Send test" button.
pub notify: NotifyStore,
/// Lock-free metrics counters surfaced at `/metrics` in Prometheus /// Lock-free metrics counters surfaced at `/metrics` in Prometheus
/// text format. Cheap to clone (handles to atomics). /// text format. Cheap to clone (handles to atomics).
pub metrics: Metrics, pub metrics: Metrics,
@@ -44,11 +55,32 @@ pub struct AppState {
/// `smb_dir` at startup; `None` in pure-Linux-only deployments where /// `smb_dir` at startup; `None` in pure-Linux-only deployments where
/// Windows support is not wired in. Settings toggle drives start/stop. /// Windows support is not wired in. Settings toggle drives start/stop.
pub smb: Option<Arc<SmbManager>>, pub smb: Option<Arc<SmbManager>>,
/// NFS share manager. Always present (mounting is opt-in by the /// v0.4.65: SMB share manager — userspace consumer of remote SMB
/// operator from the Storage tab); `add()` requires `mount.nfs` to be /// shares via Samba's `smbclient` CLI. Replaces the kernel-mount
/// available in the runtime image. Surfaces errors per-mount rather /// NFS path that v0.4.64 shipped; that path didn't work on hosts
/// than failing the global state. /// (Unraid, etc.) whose kernel ships without the nfs/cifs client
pub nfs: NfsManager, /// modules, and no container-side configuration could fix it.
/// `smbclient` does the SMB protocol over a plain TCP socket in
/// userspace — works in any container, no special caps required.
pub smb_shares: SmbShareManager,
/// v0.4.67: NFSv3 share manager — pure-Rust userspace consumer
/// via the `nfs3_client` crate. Ships alongside the SMB manager
/// so operators pick whichever protocol their NAS prefers.
/// In-process (no subprocess); supports HTTP Range requests on
/// NFS-sourced ISOs because NFSv3 READ3 takes an explicit offset.
pub nfs_shares: NfsShareManager,
/// v0.5.5: SFTP-over-SSH share manager — pure-Rust userspace
/// consumer via `russh` + `russh-sftp` (ring backend, no OpenSSL).
/// Ships alongside SMB/NFS as the third remote-library protocol.
/// In-process (no subprocess, no kernel mount); supports HTTP Range
/// requests because SFTP opens a seekable file handle. Authenticates
/// the server's SSH host key on a trust-on-first-use basis.
pub sftp_shares: SftpShareManager,
/// v0.5.2: uploaded unattended-install answer files (Kickstart /
/// Preseed / Autoinstall / Windows answer files). Served on demand to
/// booting clients with per-host hostname/IP/MAC templating; lives in
/// its own directory, never the ISO listing or PXE menu.
pub unattended: UnattendedStore,
/// Browser chunked upload state. Multipart uploads still go straight /// Browser chunked upload state. Multipart uploads still go straight
/// through `IsoStore`, but the UI uses sessions so large ISO transfers /// through `IsoStore`, but the UI uses sessions so large ISO transfers
/// can show deterministic progress and leave visible partial files. /// can show deterministic progress and leave visible partial files.
+297 -56
View File
@@ -88,8 +88,16 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
"isos" | "images" => Ok(isos_text(state)), "isos" | "images" => Ok(isos_text(state)),
"clients" => Ok(clients_text(state)), "clients" => Ok(clients_text(state)),
"queue" => queue_command(state, tail).await, "queue" => queue_command(state, tail).await,
"nfs" => nfs_command(state, tail).await, // `smb` controls the outbound Samba server for Windows
// install media. `share` lists/manages remote SMB shares
// OpenPXE pulls ISOs from (v0.4.65). `nfs` is the parallel
// command for remote NFSv3 shares (v0.4.67, in-process via
// nfs3_client — not the v0.4.64 kernel-mount path).
"share" | "smb-share" => smb_share_command(state, tail).await,
"smb" => smb_command(state, tail).await, "smb" => smb_command(state, tail).await,
"nfs" => nfs_share_command(state, tail).await,
// v0.5.5: SFTP-over-SSH remote shares (in-process russh client).
"sftp" => sftp_share_command(state, tail).await,
"log" => log_command(state, tail), "log" => log_command(state, tail),
"whoami" => Ok("operator".to_string()), "whoami" => Ok("operator".to_string()),
"echo" => Ok(tail.join(" ")), "echo" => Ok(tail.join(" ")),
@@ -107,18 +115,26 @@ fn status_text(s: &AppState) -> String {
let clients = s.clients.list(); let clients = s.clients.list();
let queue_entries = s.queue.list(); let queue_entries = s.queue.list();
let smb = s.smb.as_ref().map(|m| m.snapshot()); let smb = s.smb.as_ref().map(|m| m.snapshot());
let nfs = s.nfs.list(); let smb_shares = s.smb_shares.list();
let nfs_active = nfs.iter().filter(|m| m.mounted).count(); let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count();
// v0.4.67: NFSv3 sources too.
let nfs_shares = s.nfs_shares.list();
let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count();
// v0.5.5: SFTP-over-SSH sources too.
let sftp_shares = s.sftp_shares.list();
let sftp_reachable = sftp_shares.iter().filter(|m| m.reachable).count();
format!( format!(
"OpenPXE {ver}\n\ "OpenPXE {ver}\n\
base url: {base}\n\ base url: {base}\n\
interface: {nic}\n\ interface: {nic}\n\
uptime: {up}\n\ uptime: {up}\n\
isos: {n_isos} (local: {n_local}, 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: {smb}\n\ smb server: {smb}\n\
nfs mounts: {n_total} configured ({n_active} active)\n", smb shares: {n_smb_total} configured ({n_smb_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() {
@@ -132,15 +148,27 @@ fn status_text(s: &AppState) -> String {
.iter() .iter()
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Local)) .filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Local))
.count(), .count(),
n_smb = isos
.iter()
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Smb { .. }))
.count(),
n_nfs = isos n_nfs = isos
.iter() .iter()
.filter(|i| !matches!(i.source, openpxe_iso_store::IsoSource::Local)) .filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Nfs { .. }))
.count(),
n_sftp = isos
.iter()
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Sftp { .. }))
.count(), .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:?}")),
n_total = nfs.len(), n_smb_total = smb_shares.len(),
n_active = nfs_active, n_smb_active = smb_reachable,
n_nfs_total = nfs_shares.len(),
n_nfs_active = nfs_reachable,
n_sftp_total = sftp_shares.len(),
n_sftp_active = sftp_reachable,
) )
} }
@@ -158,7 +186,11 @@ fn isos_text(s: &AppState) -> String {
for i in isos { for i in isos {
let src = match i.source { let src = match i.source {
openpxe_iso_store::IsoSource::Local => "local".to_string(), openpxe_iso_store::IsoSource::Local => "local".to_string(),
openpxe_iso_store::IsoSource::Nfs { mount_id, .. } => format!("nfs:{mount_id}"), openpxe_iso_store::IsoSource::Smb { share_id, .. } => format!("smb:{share_id}"),
// v0.4.67: NFSv3 via in-process nfs3_client.
openpxe_iso_store::IsoSource::Nfs { share_id, .. } => format!("nfs:{share_id}"),
// v0.5.5: SFTP-over-SSH via in-process russh.
openpxe_iso_store::IsoSource::Sftp { share_id, .. } => format!("sftp:{share_id}"),
}; };
let _ = writeln!( let _ = writeln!(
out, out,
@@ -264,31 +296,130 @@ async fn queue_command(s: &AppState, args: &[String]) -> Result<String, String>
} }
} }
// ── nfs ──────────────────────────────────────────────────────────────── // ── share (v0.4.65: SMB shares) ─────────────────────────────────────────
async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> { async fn smb_share_command(s: &AppState, args: &[String]) -> Result<String, String> {
match args.first().map(String::as_str) { match args.first().map(String::as_str) {
None | Some("list") => { None | Some("list") => {
let mounts = s.nfs.list(); let shares = s.smb_shares.list();
if mounts.is_empty() { if shares.is_empty() {
return Ok("(no NFS mounts configured)".into()); return Ok("(no SMB shares configured)".into());
} }
let mut out = String::new(); let mut out = String::new();
let _ = writeln!( let _ = writeln!(
out, out,
"{:<24} {:<6} {:<7} {:<6} TARGET", "{:<24} {:<7} {:<6} {:<6} TARGET",
"ID", "VER", "STATUS", "ISOS" "ID", "STATUS", "AUTH", "ISOS"
); );
for m in mounts { for m in shares {
let status = if m.mounted { "ok" } else { "down" }; let status = if m.reachable { "ok" } else { "down" };
let auth = if m.guest { "guest" } else { "user" };
let _ = writeln!( let _ = writeln!(
out, out,
"{:<24} {:<6} {:<7} {:<6} {}:{}", "{:<24} {:<7} {:<6} {:<6} //{}/{}",
truncate(&m.id, 24),
status,
auth,
m.iso_count,
m.server,
m.share,
);
if let Some(e) = m.last_error {
let _ = writeln!(out, " error: {e}");
}
if let Some(h) = m.last_hint {
let _ = writeln!(out, " hint: {h}");
}
}
Ok(out)
}
Some("add") => {
// share add //server/share [guest|user:password]
let target = args.get(1).ok_or_else(|| {
"usage: share add //server/share [guest|user:password]".to_string()
})?;
// Accept either `//server/share` (UNC-style) or
// `server:share` (shorter to type).
let stripped = target.trim_start_matches('/').trim_start_matches('\\');
let (server, share) = if let Some((s, p)) = stripped.split_once('/') {
(s, p)
} else if let Some((s, p)) = stripped.split_once(':') {
(s, p)
} else {
return Err("target must be '//server/share' or 'server:share'".into());
};
// Auth spec: "guest" or "user:password". Default: guest.
let auth = args.get(2).cloned().unwrap_or_else(|| "guest".into());
let (guest, username, password) = if auth == "guest" {
(true, None, None)
} else if let Some((u, p)) = auth.split_once(':') {
(false, Some(u.to_string()), Some(p.to_string()))
} else {
return Err("auth must be 'guest' or 'user:password'".into());
};
let req = openpxe_iso_store::SmbAddRequest {
server: server.to_string(),
share: share.to_string(),
username,
password,
guest,
port: None,
};
match s.smb_shares.add(req).await {
Ok(m) => Ok(format!("added {} ({} isos)", m.id, m.iso_count)),
Err(e) => {
let mut out = format!("add failed: {}", e.error);
if let Some(h) = e.hint {
out.push_str("\nhint: ");
out.push_str(&h);
}
Err(out)
}
}
}
Some("remove") => {
let id = args
.get(1)
.ok_or_else(|| "usage: share remove <id>".to_string())?;
match s.smb_shares.remove(id).await {
Ok(()) => Ok(format!("removed {id}")),
Err(e) => Err(format!("remove failed: {e}")),
}
}
Some("scan") => {
let id = args
.get(1)
.ok_or_else(|| "usage: share scan <id>".to_string())?;
match s.smb_shares.rescan(id).await {
Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
Err(e) => Err(format!("scan failed: {e}")),
}
}
Some(other) => Err(format!(
"unknown share subcommand: {other}\ntry: share [list|add|remove|scan]"
)),
}
}
// ── nfs (v0.4.67: in-process NFSv3 via nfs3_client) ────────────────────
async fn nfs_share_command(s: &AppState, args: &[String]) -> Result<String, String> {
match args.first().map(String::as_str) {
None | Some("list") => {
let shares = s.nfs_shares.list();
if shares.is_empty() {
return Ok("(no NFS shares configured)".into());
}
let mut out = String::new();
let _ = writeln!(out, "{:<24} {:<7} {:<6} TARGET", "ID", "STATUS", "ISOS");
for m in shares {
let status = if m.reachable { "ok" } else { "down" };
let _ = writeln!(
out,
"{:<24} {:<7} {:<6} {}:{}",
truncate(&m.id, 24), truncate(&m.id, 24),
match m.version {
openpxe_iso_store::NfsVersion::V3 => "v3",
openpxe_iso_store::NfsVersion::V41 => "v4.1",
},
status, status,
m.iso_count, m.iso_count,
m.server, m.server,
@@ -297,56 +428,156 @@ async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
if let Some(e) = m.last_error { if let Some(e) = m.last_error {
let _ = writeln!(out, " error: {e}"); let _ = writeln!(out, " error: {e}");
} }
if let Some(h) = m.last_hint {
let _ = writeln!(out, " hint: {h}");
}
} }
Ok(out) Ok(out)
} }
Some("mount") => { Some("add") => {
// nfs mount <server>:<export> [v3|v41] [ro|rw] // nfs add <server>:<export> [port]
let target = args let target = args
.get(1) .get(1)
.ok_or_else(|| "usage: nfs mount <server>:<export> [v3|v41] [ro|rw]".to_string())?; .ok_or_else(|| "usage: nfs add <server>:<export> [port]".to_string())?;
let (server, export) = target let (server, export) = target
.split_once(':') .split_once(':')
.ok_or_else(|| "target must be 'server:/export'".to_string())?; .ok_or_else(|| "target must be 'server:/export'".to_string())?;
let version = match args.get(2).map(String::as_str) { let port = args.get(2).and_then(|s| s.parse::<u16>().ok());
Some("v3") => openpxe_iso_store::NfsVersion::V3,
Some("v41") | None => openpxe_iso_store::NfsVersion::V41,
Some(other) => {
return Err(format!("unknown nfs version: {other} (expect v3 or v41)"))
}
};
let read_only = !matches!(args.get(3).map(String::as_str), Some("rw"));
let req = openpxe_iso_store::NfsAddRequest { let req = openpxe_iso_store::NfsAddRequest {
server: server.to_string(), server: server.to_string(),
export: export.to_string(), export: export.to_string(),
version, port,
read_only,
}; };
match s.nfs.add(req).await { match s.nfs_shares.add(req).await {
Ok(m) => Ok(format!("mounted {} ({} isos)", m.id, m.iso_count)), Ok(m) => Ok(format!("added {} ({} isos)", m.id, m.iso_count)),
Err(e) => Err(format!("mount failed: {e}")), 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("unmount") => { Some("remove") => {
let id = args let id = args
.get(1) .get(1)
.ok_or_else(|| "usage: nfs unmount <id>".to_string())?; .ok_or_else(|| "usage: nfs remove <id>".to_string())?;
match s.nfs.remove(id).await { match s.nfs_shares.remove(id).await {
Ok(()) => Ok(format!("unmounted {id}")), Ok(()) => Ok(format!("removed {id}")),
Err(e) => Err(format!("unmount failed: {e}")), Err(e) => Err(format!("remove failed: {e}")),
} }
} }
Some("scan") => { Some("scan") => {
let id = args let id = args
.get(1) .get(1)
.ok_or_else(|| "usage: nfs scan <id>".to_string())?; .ok_or_else(|| "usage: nfs scan <id>".to_string())?;
match s.nfs.rescan(id).await { match s.nfs_shares.rescan(id).await {
Ok(n) => Ok(format!("re-scanned {id}: {n} isos")), Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
Err(e) => Err(format!("scan failed: {e}")), Err(e) => Err(format!("scan failed: {e}")),
} }
} }
Some(other) => Err(format!( Some(other) => Err(format!(
"unknown nfs subcommand: {other}\ntry: nfs [list|mount|unmount|scan]" "unknown nfs subcommand: {other}\ntry: nfs [list|add|remove|scan]"
)),
}
}
// ── sftp (v0.5.5) ────────────────────────────────────────────────────────
//
// Parallel to nfs_share_command. The terminal `add` only supports
// password auth — pasting a multiline PEM private key through the
// terminal is impractical, so key-based shares are added via the WebUI.
async fn sftp_share_command(s: &AppState, args: &[String]) -> Result<String, String> {
match args.first().map(String::as_str) {
None | Some("list") => {
let shares = s.sftp_shares.list();
if shares.is_empty() {
return Ok("(no SFTP shares configured)".into());
}
let mut out = String::new();
let _ = writeln!(out, "{:<24} {:<7} {:<6} TARGET", "ID", "STATUS", "ISOS");
for m in shares {
let status = if m.reachable { "ok" } else { "down" };
let _ = writeln!(
out,
"{:<24} {:<7} {:<6} {}@{}:{}",
truncate(&m.id, 24),
status,
m.iso_count,
m.username,
m.server,
m.export,
);
if let Some(e) = m.last_error {
let _ = writeln!(out, " error: {e}");
}
if let Some(h) = m.last_hint {
let _ = writeln!(out, " hint: {h}");
}
}
Ok(out)
}
Some("add") => {
// sftp add <user>@<server>:<export> <password> [port]
let target = args.get(1).ok_or_else(|| {
"usage: sftp add <user>@<server>:<export> <password> [port] \
(key auth: use the WebUI)"
.to_string()
})?;
let password = args
.get(2)
.ok_or_else(|| "a password is required (key auth: use the WebUI)".to_string())?;
let (user, rest) = target
.split_once('@')
.ok_or_else(|| "target must be 'user@server:/export'".to_string())?;
let (server, export) = rest
.split_once(':')
.ok_or_else(|| "target must be 'user@server:/export'".to_string())?;
let port = args.get(3).and_then(|s| s.parse::<u16>().ok());
let req = openpxe_iso_store::SftpAddRequest {
server: server.to_string(),
export: export.to_string(),
username: Some(user.to_string()),
port,
password: Some(password.clone()),
private_key: None,
passphrase: None,
};
match s.sftp_shares.add(req).await {
Ok(m) => Ok(format!("added {} ({} isos)", m.id, m.iso_count)),
Err(e) => {
let mut out = format!("add failed: {}", e.error);
if let Some(h) = e.hint {
out.push_str("\nhint: ");
out.push_str(&h);
}
Err(out)
}
}
}
Some("remove") => {
let id = args
.get(1)
.ok_or_else(|| "usage: sftp remove <id>".to_string())?;
match s.sftp_shares.remove(id).await {
Ok(()) => Ok(format!("removed {id}")),
Err(e) => Err(format!("remove failed: {e}")),
}
}
Some("scan") => {
let id = args
.get(1)
.ok_or_else(|| "usage: sftp scan <id>".to_string())?;
match s.sftp_shares.rescan(id).await {
Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
Err(e) => Err(format!("scan failed: {e}")),
}
}
Some(other) => Err(format!(
"unknown sftp subcommand: {other}\ntry: sftp [list|add|remove|scan]"
)), )),
} }
} }
@@ -487,13 +718,23 @@ OpenPXE terminal — available commands:
queue assign-all <target> assign every waiting client queue assign-all <target> assign every waiting client
queue release <entry_id> release one queued client queue release <entry_id> release one queued client
nfs list list NFS mounts share list list configured SMB shares
nfs mount <s>:<e> [v3|v41] [ro|rw] add and mount an NFS share share add //srv/share [auth] add an SMB share; auth = 'guest' or 'user:pass'
nfs unmount <id> unmount and forget a share share remove <id> forget an SMB share
nfs scan <id> re-scan a share for new ISOs share scan <id> re-list a share for new ISOs
smb status SMB (Samba) state nfs list list configured NFSv3 shares
smb start | stop | reload control smbd nfs add <srv>:<export> [port] add an NFSv3 share
nfs remove <id> forget an NFS share
nfs scan <id> re-list an NFS share for new ISOs
sftp list list configured SFTP-over-SSH shares
sftp add <user>@<srv>:<export> <pass> [port] add an SFTP share (key auth: WebUI)
sftp remove <id> forget an SFTP share
sftp scan <id> re-list an SFTP share for new ISOs
smb status outbound Samba state (Windows install media)
smb start | stop | reload control the outbound smbd
log clear drop the in-memory log ring buffer log clear drop the in-memory log ring buffer
log tail [n] show the last n buffered lines (default 20) log tail [n] show the last n buffered lines (default 20)
@@ -508,10 +749,10 @@ mod tests {
#[test] #[test]
fn shell_split_basic() { fn shell_split_basic() {
assert_eq!(shell_split(""), Vec::<String>::new()); assert_eq!(shell_split(""), Vec::<String>::new());
assert_eq!(shell_split("nfs list"), vec!["nfs", "list"]); assert_eq!(shell_split("share list"), vec!["share", "list"]);
assert_eq!( assert_eq!(
shell_split("nfs mount 10.0.0.5:/srv v41 ro"), shell_split("share add //nas/isos guest"),
vec!["nfs", "mount", "10.0.0.5:/srv", "v41", "ro"] vec!["share", "add", "//nas/isos", "guest"]
); );
} }
+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;
File diff suppressed because it is too large Load Diff
+12
View File
@@ -35,5 +35,17 @@ libc = "0.2"
# encoder. Keeps the build slim — no JPEG2000, TIFF, BMP, etc. # encoder. Keeps the build slim — no JPEG2000, TIFF, BMP, etc.
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
# v0.4.67: pure-Rust NFSv3 client for reading remote ISOs without a
# kernel mount. See crates/iso-store/src/nfs_share.rs for usage.
nfs3_client = { workspace = true }
nfs3_types = { workspace = true }
# v0.5.5: pure-Rust SSH/SFTP client (ring backend) for the SFTP remote
# share path. See crates/iso-store/src/sftp_share.rs for usage.
russh = { workspace = true }
russh-sftp = { workspace = true }
# Needed for the Stream trait that wraps the mpsc receiver feeding
# NFS read-loop bytes into axum's Body::from_stream.
futures = { workspace = true }
[dev-dependencies] [dev-dependencies]
tempfile = "3.12" tempfile = "3.12"
+101 -3
View File
@@ -85,12 +85,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;
if report.family == DistroFamily::Unknown { 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 {
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 +183,45 @@ 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))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -179,4 +243,38 @@ 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 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"
)));
}
} }
+27 -4
View File
@@ -18,18 +18,41 @@
pub mod entry; pub mod entry;
pub mod introspect; pub mod introspect;
pub mod nfs; 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 store; pub mod store;
pub mod unattended;
pub mod windows; pub mod windows;
pub use entry::{BootEntry, BootKind, KernelArgs}; pub use entry::{BootEntry, BootKind, KernelArgs};
pub use introspect::{DistroFamily, IntrospectionReport}; pub use introspect::{DistroFamily, IntrospectionReport};
pub use nfs::{NfsAddRequest, NfsManager, NfsMount, NfsVersion}; // v0.4.65: kernel-mount NFS is gone. SMB shares via Samba's userspace
// `smbclient` CLI replaced it — works in any container (no
// CAP_SYS_ADMIN, no host kernel modules), matching how Bootimus and
// every other PXE/imaging tool that supports network storage handles
// it.
pub use smb::{extract_windows_iso, SmbManager, SmbState}; pub use smb::{extract_windows_iso, SmbManager, SmbState};
pub use smb_share::{SmbAddRequest, SmbShare, SmbShareError, SmbShareManager, SmbStream};
// v0.4.67: NFS is back — this time as an in-process userspace NFSv3
// client (the `nfs3_client` crate) rather than a kernel mount. Same
// "works in any container" property as SMB, plus support for HTTP
// Range requests because NFSv3 READ3 takes an explicit offset.
pub use nfs_share::{NfsAddRequest, NfsShare, NfsShareError, NfsShareManager, NfsStream};
// v0.5.5: SFTP-over-SSH remote shares via the pure-Rust `russh` +
// `russh-sftp` crates (ring backend — no OpenSSL, no new C deps). Like
// NFS, supports HTTP Range requests because SFTP opens a seekable file
// handle. See crates/iso-store/src/sftp_share.rs.
pub use sftp_share::{
SftpAddRequest, SftpAuthKind, SftpShare, SftpShareError, SftpShareManager, SftpStream,
};
pub use store::{ pub use store::{
generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore, generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore, UploadHandle,
UploadHandle, };
pub use unattended::{
classify as classify_unattended, render_template, UnattendedKind, UnattendedMeta,
UnattendedStore, MAX_UNATTENDED_BYTES,
}; };
pub use windows::{WimPatcher, WinPatchState}; pub use windows::{WimPatcher, WinPatchState};
-558
View File
@@ -1,558 +0,0 @@
//! NFS share manager.
//!
//! Lets an operator mount a remote NFS export as an ISO source instead of
//! uploading every ISO into the container's PVC. Supports NFSv3 and
//! NFSv4.1 — the two versions the user explicitly asked for.
//!
//! ## How it works
//!
//! 1. Operator submits a mount spec via the Storage tab:
//! `{ server: "10.0.0.20", export: "/srv/isos", version: "v41" }`.
//! 2. We slugify a stable id, mkdir `<work_dir>/nfs/<id>/`, then shell out
//! to `/bin/mount -t nfs -o vers=...,ro,nolock server:export local`.
//! 3. On success we walk the mount point looking for `*.iso` files and
//! register each one with the `IsoStore` as an external source — same
//! introspection pipeline as a web upload, but no sha256 (the bytes
//! live on a remote machine; hashing them would suck them through the
//! network on every restart).
//! 4. On failure we record `last_error` on the spec and persist anyway
//! so the UI can show a row in red rather than silently dropping it.
//!
//! ## Operational notes
//!
//! - Mounting NFS inside a container needs `CAP_SYS_ADMIN` and the
//! `nfs-common` package. The default image ships these (see Dockerfile).
//! - On OpenShift, the SCC must allow `CAP_SYS_ADMIN`. The bundled SCC
//! doesn't — operators have to opt in by switching to a more privileged
//! SCC or running NFS mounts as a CSI driver outside the pod.
//! - Mount commands are issued sequentially under a single mutex to avoid
//! `mount` racing on the same target dir.
//!
//! ## Persistence
//!
//! Mount specs (without runtime state) live at `<work_dir>/nfs.json`,
//! re-mounted on startup. Mounts that fail to come back online keep their
//! spec and their `last_error` so the operator sees what happened.
use crate::introspect::{introspect, IntrospectionReport};
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
use openpxe_core::{Error, Result};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use time::OffsetDateTime;
use tokio::process::Command;
/// Wire-protocol versions we support. Keep this enum closed — silently
/// accepting "auto" or letting the kernel negotiate would mean operators
/// could never confirm which version is in use.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NfsVersion {
/// NFSv3 — UDP/TCP, separate `mountd` protocol. Required for many
/// older NAS appliances.
V3,
/// NFSv4.1 — single TCP port (2049), session-based. Modern default.
V41,
}
impl NfsVersion {
fn vers_arg(self) -> &'static str {
match self {
Self::V3 => "vers=3",
Self::V41 => "vers=4.1",
}
}
}
/// One configured mount. The id is generated from server+export so the
/// operator can re-add the same export idempotently.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NfsMount {
pub id: String,
pub server: String,
pub export: String,
pub version: NfsVersion,
/// Read-only by default — most ISO libraries are. Operators that need
/// write can flip this off but OpenPXE itself never writes.
pub read_only: bool,
/// Local mount point under `<work_dir>/nfs/`.
pub local_path: PathBuf,
/// Whether the mount is currently active.
pub mounted: bool,
/// Last error encountered on a `mount` or `umount` attempt; cleared on
/// success.
pub last_error: Option<String>,
#[serde(with = "time::serde::rfc3339::option")]
pub last_attempt: Option<OffsetDateTime>,
/// Number of `.iso` files found on the share (re-counted on each scan).
pub iso_count: u32,
}
/// Spec submitted by the UI. Server and export are normalized before use.
#[derive(Debug, Clone, Deserialize)]
pub struct NfsAddRequest {
pub server: String,
pub export: String,
#[serde(default = "default_version")]
pub version: NfsVersion,
#[serde(default = "default_ro")]
pub read_only: bool,
}
fn default_version() -> NfsVersion {
NfsVersion::V41
}
fn default_ro() -> bool {
true
}
#[derive(Debug, Default)]
struct Inner {
mounts: HashMap<String, NfsMount>,
}
/// Manages NFS mounts and surfaces them as ISO sources.
///
/// Cheap to clone — internal state is `Arc<Mutex<...>>`.
#[derive(Debug, Clone)]
pub struct NfsManager {
work_root: Arc<PathBuf>,
state_path: Arc<PathBuf>,
inner: Arc<Mutex<Inner>>,
iso_store: IsoStore,
/// Single-writer lock around the actual `mount`/`umount` shell-outs;
/// avoids racing on the same target directory.
mount_lock: Arc<tokio::sync::Mutex<()>>,
}
impl NfsManager {
/// Construct a manager rooted at `work_dir`. Mount points live under
/// `<work_dir>/nfs/<id>/`. State persists to `<work_dir>/nfs.json`.
#[must_use]
pub fn new(work_dir: &Path, iso_store: IsoStore) -> Self {
let work_root = work_dir.join("nfs");
let state_path = work_dir.join("nfs.json");
Self {
work_root: Arc::new(work_root),
state_path: Arc::new(state_path),
inner: Arc::new(Mutex::new(Inner::default())),
iso_store,
mount_lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
/// Where this manager mounts shares. Used by `IsoStore` to resolve
/// NFS-backed `IsoMeta`s to their on-disk path.
#[must_use]
pub fn mount_root(&self) -> PathBuf {
self.work_root.as_ref().clone()
}
/// Load persisted state and re-attempt every mount. Errors are logged
/// per-mount but never fail the call — startup must not block on a
/// remote NFS server being slow.
pub async fn load_and_remount(&self) -> Result<()> {
tokio::fs::create_dir_all(self.work_root.as_path()).await?;
let mounts = match tokio::fs::read_to_string(self.state_path.as_path()).await {
Ok(text) => serde_json::from_str::<Vec<NfsMount>>(&text).unwrap_or_default(),
Err(_) => Vec::new(),
};
for mut m in mounts {
// Always start from "not mounted" — the kernel state was lost
// when the process died. We'll try to remount each one.
m.mounted = false;
m.last_error = None;
self.inner.lock().mounts.insert(m.id.clone(), m.clone());
if let Err(e) = self.try_mount(&m.id).await {
tracing::warn!(
target: "openpxe::nfs",
id = %m.id, error = %e,
"could not remount NFS share on startup"
);
}
}
Ok(())
}
/// Add a new mount. Returns the resulting `NfsMount` (with `mounted`
/// reflecting reality) or an error if the spec was invalid.
pub async fn add(&self, req: NfsAddRequest) -> Result<NfsMount> {
let server = req.server.trim().to_string();
let export = req.export.trim().to_string();
if server.is_empty() {
return Err(Error::Invalid("server is required".into()));
}
if !export.starts_with('/') {
return Err(Error::Invalid("export path must start with '/'".into()));
}
let id = mount_id(&server, &export);
let local_path = self.work_root.join(&id);
tokio::fs::create_dir_all(&local_path).await?;
let mount = NfsMount {
id: id.clone(),
server,
export,
version: req.version,
read_only: req.read_only,
local_path,
mounted: false,
last_error: None,
last_attempt: None,
iso_count: 0,
};
self.inner.lock().mounts.insert(id.clone(), mount);
self.persist_locked();
self.try_mount(&id).await?;
Ok(self.get(&id).expect("mount just inserted"))
}
/// Unmount and forget a share. Removes any ISOs it contributed from
/// the IsoStore and deletes the local mount point. Idempotent.
pub async fn remove(&self, id: &str) -> Result<()> {
// Best-effort umount; even if it fails (e.g. server unreachable)
// we still want to drop the in-memory record.
let _ = self.umount_one(id).await;
let local_path = {
let mut g = self.inner.lock();
g.mounts.remove(id).map(|m| m.local_path)
};
self.persist_locked();
self.iso_store.drop_external_source(id);
if let Some(p) = local_path {
// rmdir only — never recurse, the mount could still be live
// on some kernel error path and we don't want to nuke a
// remote filesystem.
let _ = tokio::fs::remove_dir(&p).await;
}
Ok(())
}
/// Re-scan a mounted share for ISOs, refreshing the IsoStore.
pub async fn rescan(&self, id: &str) -> Result<u32> {
let mount = self
.get(id)
.ok_or_else(|| Error::Invalid(format!("no such mount '{id}'")))?;
if !mount.mounted {
return Err(Error::Invalid(format!("mount '{id}' is not active")));
}
let count = self.scan_and_register(&mount).await?;
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
m.iso_count = count;
}
self.persist_locked();
Ok(count)
}
/// Snapshot of every configured mount.
#[must_use]
pub fn list(&self) -> Vec<NfsMount> {
let g = self.inner.lock();
let mut v: Vec<_> = g.mounts.values().cloned().collect();
v.sort_by(|a, b| a.id.cmp(&b.id));
v
}
/// Look up a single mount by id.
#[must_use]
pub fn get(&self, id: &str) -> Option<NfsMount> {
self.inner.lock().mounts.get(id).cloned()
}
// ── internals ─────────────────────────────────────────────────────
async fn try_mount(&self, id: &str) -> Result<()> {
let _g = self.mount_lock.lock().await;
let m = self
.get(id)
.ok_or_else(|| Error::Invalid(format!("no such mount '{id}'")))?;
let now = OffsetDateTime::now_utc();
// Already mounted? Skip — `mount` would error on a busy target
// and confuse the operator's UI status.
if is_mountpoint(&m.local_path).await {
self.update_status(id, true, None, now);
// Even though already mounted, we still want a fresh ISO count.
let count = self.scan_and_register(&m).await.unwrap_or(0);
self.update_iso_count(id, count);
return Ok(());
}
let opts = mount_options(&m);
let target = format!("{}:{}", m.server, m.export);
let output = Command::new("mount")
.arg("-t")
.arg("nfs")
.arg("-o")
.arg(&opts)
.arg(&target)
.arg(&m.local_path)
.output()
.await;
match output {
Ok(out) if out.status.success() => {
tracing::info!(
target: "openpxe::nfs",
id = %id, server = %m.server, export = %m.export,
version = ?m.version,
"NFS mount succeeded"
);
self.update_status(id, true, None, now);
let count = self.scan_and_register(&m).await.unwrap_or(0);
self.update_iso_count(id, count);
Ok(())
}
Ok(out) => {
let err = format!(
"mount exit {}: {}",
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stderr).trim()
);
tracing::warn!(target: "openpxe::nfs", id = %id, "{err}");
self.update_status(id, false, Some(err.clone()), now);
Err(Error::Invalid(err))
}
Err(e) => {
let err = format!("could not exec /bin/mount: {e}");
tracing::error!(target: "openpxe::nfs", id = %id, "{err}");
self.update_status(id, false, Some(err.clone()), now);
Err(Error::Invalid(err))
}
}
}
async fn umount_one(&self, id: &str) -> Result<()> {
let _g = self.mount_lock.lock().await;
let Some(m) = self.get(id) else { return Ok(()) };
if !is_mountpoint(&m.local_path).await {
self.update_status(id, false, None, OffsetDateTime::now_utc());
return Ok(());
}
// -l = lazy: detach immediately, finish when no process has a
// handle. Important if a stale ISO read is still in flight.
let out = Command::new("umount")
.arg("-l")
.arg(&m.local_path)
.output()
.await;
match out {
Ok(o) if o.status.success() => {
self.update_status(id, false, None, OffsetDateTime::now_utc());
Ok(())
}
Ok(o) => {
let e = format!(
"umount exit {}: {}",
o.status.code().unwrap_or(-1),
String::from_utf8_lossy(&o.stderr).trim()
);
self.update_status(id, false, Some(e.clone()), OffsetDateTime::now_utc());
Err(Error::Invalid(e))
}
Err(e) => {
let e = format!("could not exec /bin/umount: {e}");
self.update_status(id, false, Some(e.clone()), OffsetDateTime::now_utc());
Err(Error::Invalid(e))
}
}
}
/// Walk the mount point for `*.iso` files, introspect each one, and
/// register it with the IsoStore as an NFS-sourced entry. Returns the
/// count of ISOs registered.
async fn scan_and_register(&self, m: &NfsMount) -> Result<u32> {
// Drop any prior entries from this mount before re-registering, so
// a removed file disappears from the store.
self.iso_store.drop_external_source(&m.id);
let mut walker = tokio::fs::read_dir(&m.local_path).await?;
let mut count = 0u32;
while let Some(entry) = walker.next_entry().await? {
let p = entry.path();
if p.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
!= Some("iso")
{
continue;
}
let filename = match p.file_name().and_then(|s| s.to_str()) {
Some(f) => f.to_string(),
None => continue,
};
let size = tokio::fs::metadata(&p).await?.len();
// Introspection is sync + IO-bound (reads ISO9660 PVD). Push
// it to a blocking thread so the runtime stays responsive on
// a slow share.
let p_owned = p.clone();
let report: IntrospectionReport =
tokio::task::spawn_blocking(move || introspect(&p_owned))
.await
.map_err(|e| Error::Other(e.into()))?;
let id = format!("nfs-{}-{}", m.id, slugify_str(&filename));
let boot_entries = generate_boot_entries_for(&id, &filename, &report);
let source = IsoSource::Nfs {
mount_id: m.id.clone(),
relative_path: filename.clone(),
};
self.iso_store
.register_external(id, filename, size, report, boot_entries, source);
count += 1;
}
Ok(count)
}
fn update_status(&self, id: &str, mounted: bool, err: Option<String>, ts: OffsetDateTime) {
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
m.mounted = mounted;
m.last_error = err;
m.last_attempt = Some(ts);
}
self.persist_locked();
}
fn update_iso_count(&self, id: &str, count: u32) {
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
m.iso_count = count;
}
self.persist_locked();
}
/// Atomically replace the on-disk JSON with the current state.
/// Persistence errors are logged, never propagated — settings live in
/// memory authoritatively, matching the SettingsStore policy.
fn persist_locked(&self) {
let mounts: Vec<NfsMount> = self.inner.lock().mounts.values().cloned().collect();
let path = self.state_path.as_path();
let tmp = path.with_extension("json.tmp");
let body = match serde_json::to_vec_pretty(&mounts) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::nfs", "serialize NFS state: {e}");
return;
}
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::nfs", "write NFS state tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, path) {
tracing::warn!(target: "openpxe::nfs", "rename NFS state: {e}");
}
}
}
fn mount_options(m: &NfsMount) -> String {
let mut opts = vec![m.version.vers_arg().to_string()];
if m.read_only {
opts.push("ro".into());
} else {
opts.push("rw".into());
}
// `nolock` for v3 — many storage appliances disable lockd; we don't
// need locking for read-only ISO access anyway.
if matches!(m.version, NfsVersion::V3) {
opts.push("nolock".into());
}
// Soft mount with a generous timeout — better to surface a hung share
// as a user-visible error than to wedge the iPXE client forever on a
// dead NFS server.
opts.push("soft".into());
opts.push("timeo=100".into());
opts.push("retrans=3".into());
opts.join(",")
}
fn mount_id(server: &str, export: &str) -> String {
let raw = format!("{server}{export}");
slugify_str(&raw)
}
/// Detect whether `path` is currently a mount point. We don't have
/// `is_mountpoint(2)`, so compare the parent's device id to the dir's;
/// if they differ the dir is a mount.
async fn is_mountpoint(path: &Path) -> bool {
let Some(parent) = path.parent() else {
return false;
};
let Ok(m1) = tokio::fs::metadata(path).await else {
return false;
};
let Ok(m2) = tokio::fs::metadata(parent).await else {
return false;
};
use std::os::unix::fs::MetadataExt;
m1.dev() != m2.dev()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_arg() {
assert_eq!(NfsVersion::V3.vers_arg(), "vers=3");
assert_eq!(NfsVersion::V41.vers_arg(), "vers=4.1");
}
#[test]
fn mount_options_v3_includes_nolock() {
let m = NfsMount {
id: "x".into(),
server: "s".into(),
export: "/e".into(),
version: NfsVersion::V3,
read_only: true,
local_path: PathBuf::from("/tmp/x"),
mounted: false,
last_error: None,
last_attempt: None,
iso_count: 0,
};
let opts = mount_options(&m);
assert!(opts.contains("vers=3"));
assert!(opts.contains("ro"));
assert!(opts.contains("nolock"));
assert!(opts.contains("soft"));
}
#[test]
fn mount_options_v41_no_nolock() {
let m = NfsMount {
id: "x".into(),
server: "s".into(),
export: "/e".into(),
version: NfsVersion::V41,
read_only: false,
local_path: PathBuf::from("/tmp/x"),
mounted: false,
last_error: None,
last_attempt: None,
iso_count: 0,
};
let opts = mount_options(&m);
assert!(opts.contains("vers=4.1"));
assert!(opts.contains("rw"));
assert!(!opts.contains("nolock"));
}
#[test]
fn mount_id_is_stable_and_safe() {
let a = mount_id("10.0.0.5", "/srv/isos");
let b = mount_id("10.0.0.5", "/srv/isos");
assert_eq!(a, b);
assert!(!a.contains('/'));
assert!(!a.contains('.'));
}
}
File diff suppressed because it is too large Load Diff
+197 -85
View File
@@ -1,86 +1,179 @@
//! Operator-logo compositor for the iPXE menu. //! PXE boot-menu background compositor.
//! //!
//! The brief: match iVentoy's polished centered-logo PXE chrome with //! The brief (v0.4.69): match iVentoy's polished graphical PXE screen.
//! whatever raster the operator drops onto Settings → Branding. A wide //! iPXE built with `CONSOLE_FRAMEBUFFER` + `IMAGE_PNG` paints a PNG to
//! wordmark, a portrait stack, a square monogram — all three should //! the framebuffer via `console --picture`, then draws the text menu on
//! land in roughly the same place on the boot screen. //! top (the console's default background colour is rendered transparent
//! so the picture shows through the menu's blank cells). So what we
//! produce here is a **full-screen 1024×768 background**, not just a
//! floating logo:
//! //!
//! Approach: decode the operator's upload, fit it into a fixed //! - a solid dark field (matches the WebUI dark theme so the product
//! 1024×768 canvas with the logo horizontally centered and pinned a //! feels consistent from browser to bare metal), with
//! short margin from the top, re-encode as PNG, return the bytes. iPXE //! - the operator's uploaded logo composited across the top, leaving
//! built with `IMAGE_PNG` paints the result via `console --picture`. //! the lower ~two-thirds clear for the iPXE menu text.
//! //!
//! The 1024×768 size matches the default VESA framebuffer iPXE picks //! When no custom logo is uploaded we still return a designed
//! on most BIOS/UEFI consoles. Operators uploading 4K logos get //! background — a dark field with a centered "rainbow-horizon" disc
//! correctly downscaled; tiny icons get drawn at their native size, //! echoing the bundled OpenPXE mark — so the boot screen is graphical
//! centered, with transparent margins. //! out of the box. This replaces the old ASCII wordmark entirely.
//! //!
//! We deliberately don't ship `resvg` for SVG support — keeping the //! iPXE does **not** scale pictures (confirmed against the decoder
//! dependency surface narrow matters more than supporting SVG-only //! source): the image is painted at native pixel size and the firmware
//! brand assets. The WebUI's logo stays SVG-native (the browser //! picks the smallest video mode that fits. 1024×768 is the universal
//! rasterizes it); the PXE menu wants a raster regardless. //! safe mode, so we pin the canvas there. Operators uploading a 4K logo
//! get it downscaled to fit the top band; tiny icons paint at native
//! size, centered.
//!
//! Input formats: anything the `image` crate decodes with our enabled
//! features — PNG, JPEG, WebP, GIF. iPXE itself only consumes PNG, so
//! we always *emit* PNG regardless of what the operator uploaded; a
//! WebP logo is transcoded here transparently.
use image::imageops::FilterType; use image::imageops::FilterType;
use image::{DynamicImage, ImageError, ImageFormat, Rgba, RgbaImage}; use image::{DynamicImage, ImageError, ImageFormat, Rgba, RgbaImage};
use std::io::Cursor; use std::io::Cursor;
/// Canvas dimensions used for the composed PXE logo. Picked to match /// Canvas dimensions. Pinned to 1024×768 — the universal framebuffer
/// the framebuffer dimensions iPXE picks on most BIOS/UEFI consoles — /// mode every BIOS/UEFI console supports, and iPXE doesn't scale.
/// gives a 1:1 paint with no scaling at the firmware layer.
pub const CANVAS_W: u32 = 1024; pub const CANVAS_W: u32 = 1024;
pub const CANVAS_H: u32 = 768; pub const CANVAS_H: u32 = 768;
/// Maximum dimensions for the operator's logo inside the canvas. Any /// Bounding box for the operator's logo across the top band. Wider than
/// upload larger than this in either axis is downscaled (preserving /// the old floating-logo box because the logo now anchors a full
/// aspect ratio) to fit. Smaller uploads paint at native size. /// background rather than sitting alone on transparency.
const LOGO_MAX_W: u32 = 600; const LOGO_MAX_W: u32 = 760;
const LOGO_MAX_H: u32 = 200; const LOGO_MAX_H: u32 = 200;
/// Top margin in pixels from the canvas's top edge to the logo's top /// Top margin from the canvas top to the logo's top edge.
/// edge. Matches the visual rhythm of iVentoy's screen (logo at top, const LOGO_TOP_MARGIN: u32 = 72;
/// menu below).
const LOGO_TOP_MARGIN: u32 = 64;
/// Compose `src_bytes` (any PNG/JPEG/WebP/GIF) into a centered-top /// Background fill — a near-black with a faint blue cast, matching the
/// 1024×768 PNG and return the encoded bytes. /// WebUI's dark theme surface so the product reads as one piece from
/// browser to PXE screen.
const BG: Rgba<u8> = Rgba([11, 14, 22, 255]);
/// Compose the operator's uploaded raster (`Some`) — or the default
/// OpenPXE mark (`None`) — into a full-screen 1024×768 PNG background
/// and return the encoded bytes.
/// ///
/// Errors when the source can't be decoded or the encoded buffer can't /// Errors only when a provided `src_bytes` can't be decoded; the
/// be written (only really fires on out-of-memory; the encoder itself /// `None` path and the PNG encode are infallible for our fixed canvas.
/// is infallible for well-formed inputs). pub fn compose_pxe_background(src_bytes: Option<&[u8]>) -> Result<Vec<u8>, ImageError> {
pub fn compose_pxe_logo(src_bytes: &[u8]) -> Result<Vec<u8>, ImageError> { let mut canvas: RgbaImage = RgbaImage::from_pixel(CANVAS_W, CANVAS_H, BG);
let logo = image::load_from_memory(src_bytes)?;
// Resize-fit if the upload exceeds our bounding box. `Lanczos3`
// keeps the antialiasing crisp on the framebuffer console; it's a
// touch slower than `Triangle` but the operator hits this endpoint
// once per boot at most.
let logo = downscale_to_fit(logo, LOGO_MAX_W, LOGO_MAX_H);
let logo_rgba = logo.to_rgba8();
// Transparent canvas. iPXE 1.21+ honours alpha-channel transparency match src_bytes {
// on framebuffer consoles; older builds simply draw the alpha as Some(bytes) => {
// black, which still gives a sensible look. let logo = image::load_from_memory(bytes)?;
let mut canvas: RgbaImage = RgbaImage::from_pixel(CANVAS_W, CANVAS_H, Rgba([0, 0, 0, 0])); let logo = downscale_to_fit(logo, LOGO_MAX_W, LOGO_MAX_H);
let logo_w = logo_rgba.width(); let logo_rgba = logo.to_rgba8();
let logo_h = logo_rgba.height(); let off_x = CANVAS_W.saturating_sub(logo_rgba.width()) / 2;
// Horizontal center, top-margin from the top. Saturating math let off_y = LOGO_TOP_MARGIN.min(CANVAS_H.saturating_sub(logo_rgba.height()));
// means a logo wider than CANVAS_W (shouldn't happen after the // `overlay` alpha-composites, so a transparent-background
// downscale above, but defensive) just sits flush-left. // logo blends onto the dark field exactly as designed.
let off_x = CANVAS_W.saturating_sub(logo_w) / 2; image::imageops::overlay(&mut canvas, &logo_rgba, off_x.into(), off_y.into());
let off_y = LOGO_TOP_MARGIN.min(CANVAS_H.saturating_sub(logo_h)); }
image::imageops::overlay(&mut canvas, &logo_rgba, off_x.into(), off_y.into()); None => draw_default_mark(&mut canvas),
}
let mut out = Vec::with_capacity(64 * 1024); let mut out = Vec::with_capacity(128 * 1024);
DynamicImage::ImageRgba8(canvas).write_to(&mut Cursor::new(&mut out), ImageFormat::Png)?; DynamicImage::ImageRgba8(canvas).write_to(&mut Cursor::new(&mut out), ImageFormat::Png)?;
Ok(out) Ok(out)
} }
/// Back-compat shim for the old name — callers that pass a raw logo and
/// want it composited get the same result as `compose_pxe_background`
/// with `Some`.
pub fn compose_pxe_logo(src_bytes: &[u8]) -> Result<Vec<u8>, ImageError> {
compose_pxe_background(Some(src_bytes))
}
/// Paint a centered "rainbow-horizon" disc onto the dark canvas as the
/// default brand mark when no operator logo is set. Pure pixel math —
/// no font, no SVG rasterizer, no extra deps. A filled circle with a
/// left-to-right hue sweep echoes the bundled `logo.svg` motif.
// Casts here are all bounded small-range geometry (radius ≤ 90, canvas
// ≤ 1024) — precision loss / wrap is structurally impossible.
#[allow(clippy::cast_precision_loss, clippy::cast_possible_wrap)]
fn draw_default_mark(canvas: &mut RgbaImage) {
let radius: i32 = 90;
let cx = (CANVAS_W / 2) as i32;
let cy = (LOGO_TOP_MARGIN + 100) as i32;
// Four-stop horizontal sweep across the disc (teal → blue → violet
// → magenta) — the OpenPXE palette.
let stops = [
[0x22u8, 0xd3, 0xaa],
[0x3b, 0x82, 0xf6],
[0x8b, 0x5c, 0xf6],
[0xec, 0x48, 0x99],
];
let r2 = radius * radius;
for dy in -radius..=radius {
for dx in -radius..=radius {
if dx * dx + dy * dy > r2 {
continue;
}
// Position across the disc in [0,1] left→right.
let t = (f32::from(i16::try_from(dx + radius).unwrap_or(0)))
/ (f32::from(i16::try_from(2 * radius).unwrap_or(1)));
let color = gradient_at(&stops, t);
// Soft edge: fade alpha in the outer 3px ring.
let dist = ((dx * dx + dy * dy) as f32).sqrt();
let alpha = if dist > (radius as f32 - 3.0) {
let edge = (radius as f32 - dist).clamp(0.0, 3.0) / 3.0;
(edge * 255.0) as u8
} else {
255
};
let px = cx + dx;
let py = cy + dy;
if px >= 0 && py >= 0 && (px as u32) < CANVAS_W && (py as u32) < CANVAS_H {
blend_pixel(canvas, px as u32, py as u32, color, alpha);
}
}
}
}
/// Linear interpolate across an N-stop palette at position `t` in [0,1].
// `segments`/`idx` are ≤ palette length (4) — f32 cast is exact.
#[allow(clippy::cast_precision_loss)]
fn gradient_at(stops: &[[u8; 3]], t: f32) -> [u8; 3] {
let t = t.clamp(0.0, 1.0);
let segments = stops.len() - 1;
let scaled = t * segments as f32;
let idx = (scaled.floor() as usize).min(segments - 1);
let frac = scaled - idx as f32;
let a = stops[idx];
let b = stops[idx + 1];
[
lerp(a[0], b[0], frac),
lerp(a[1], b[1], frac),
lerp(a[2], b[2], frac),
]
}
fn lerp(a: u8, b: u8, t: f32) -> u8 {
(f32::from(a) + (f32::from(b) - f32::from(a)) * t).round() as u8
}
/// Alpha-blend `color` at `alpha` over the existing canvas pixel.
fn blend_pixel(canvas: &mut RgbaImage, x: u32, y: u32, color: [u8; 3], alpha: u8) {
let bg = canvas.get_pixel(x, y).0;
let a = f32::from(alpha) / 255.0;
let out = Rgba([
lerp(bg[0], color[0], a),
lerp(bg[1], color[1], a),
lerp(bg[2], color[2], a),
255,
]);
canvas.put_pixel(x, y, out);
}
fn downscale_to_fit(img: DynamicImage, max_w: u32, max_h: u32) -> DynamicImage { fn downscale_to_fit(img: DynamicImage, max_w: u32, max_h: u32) -> DynamicImage {
let (w, h) = (img.width(), img.height()); let (w, h) = (img.width(), img.height());
if w <= max_w && h <= max_h { if w <= max_w && h <= max_h {
return img; return img;
} }
// Preserve aspect ratio. `resize` clamps to the smaller of the
// two scale factors so we never overshoot the bounding box.
img.resize(max_w, max_h, FilterType::Lanczos3) img.resize(max_w, max_h, FilterType::Lanczos3)
} }
@@ -99,54 +192,73 @@ mod tests {
} }
#[test] #[test]
fn compose_emits_canvas_sized_png() { fn custom_logo_emits_canvas_sized_png_with_dark_field() {
let src = solid_png(120, 60, [200, 50, 50]); let src = solid_png(120, 60, [200, 50, 50]);
let out = compose_pxe_logo(&src).unwrap(); let out = compose_pxe_background(Some(&src)).unwrap();
// Round-trip the output and confirm dimensions. let img = image::load_from_memory(&out).unwrap().to_rgba8();
let img = image::load_from_memory(&out).unwrap();
assert_eq!(img.width(), CANVAS_W); assert_eq!(img.width(), CANVAS_W);
assert_eq!(img.height(), CANVAS_H); assert_eq!(img.height(), CANVAS_H);
// A far corner should be the opaque dark background fill, not
// transparent — this is a full background now, not a floating
// logo on transparency.
let corner = img.get_pixel(CANVAS_W - 1, CANVAS_H - 1);
assert_eq!(corner.0, BG.0, "corner should be the dark fill");
} }
#[test] #[test]
fn small_logo_centered_at_top_margin() { fn custom_logo_painted_in_top_band() {
let src = solid_png(100, 40, [10, 200, 10]); let src = solid_png(100, 40, [10, 200, 10]);
let out = compose_pxe_logo(&src).unwrap(); let out = compose_pxe_background(Some(&src)).unwrap();
let canvas = image::load_from_memory(&out).unwrap().to_rgba8(); let canvas = image::load_from_memory(&out).unwrap().to_rgba8();
// Pixel just inside the logo box should match the source color
// (alpha=255). Pixel near a far corner of the canvas should be
// the transparent background.
let cx = (CANVAS_W - 100) / 2; let cx = (CANVAS_W - 100) / 2;
let cy = LOGO_TOP_MARGIN; let cy = LOGO_TOP_MARGIN;
let inside = canvas.get_pixel(cx + 10, cy + 10); let inside = canvas.get_pixel(cx + 10, cy + 10);
assert_eq!(inside.0[3], 255, "logo pixel should be opaque"); assert!(
assert!(inside.0[0] < 100 && inside.0[1] > 100 && inside.0[2] < 100, "color mismatch: {inside:?}"); inside.0[1] > 100 && inside.0[0] < 100,
let corner = canvas.get_pixel(CANVAS_W - 1, CANVAS_H - 1); "logo pixel color mismatch: {inside:?}"
assert_eq!(corner.0[3], 0, "canvas corner should be transparent"); );
} }
#[test] #[test]
fn oversize_logo_is_downscaled_to_bounding_box() { fn default_background_is_dark_with_a_painted_mark() {
// 4000×800 image — bigger than LOGO_MAX_W and LOGO_MAX_H in let out = compose_pxe_background(None).unwrap();
// both axes. After downscale the output must fit; we re-decode
// the canvas, count non-transparent pixels, and confirm none
// sit outside the expected band.
let src = solid_png(4000, 800, [50, 50, 200]);
let out = compose_pxe_logo(&src).unwrap();
let canvas = image::load_from_memory(&out).unwrap().to_rgba8(); let canvas = image::load_from_memory(&out).unwrap().to_rgba8();
// Span row at the top margin should have non-transparent assert_eq!(canvas.width(), CANVAS_W);
// pixels somewhere; rows past the LOGO_TOP_MARGIN + LOGO_MAX_H assert_eq!(canvas.height(), CANVAS_H);
// should be entirely transparent. // Corner is dark fill.
let bottom_band_y = LOGO_TOP_MARGIN + LOGO_MAX_H + 10; assert_eq!(canvas.get_pixel(2, CANVAS_H - 2).0, BG.0);
for x in 0..CANVAS_W { // Center of the disc is not the background fill (something was
let p = canvas.get_pixel(x, bottom_band_y); // painted there).
assert_eq!(p.0[3], 0, "row {bottom_band_y} should be transparent at x={x}"); let center = canvas.get_pixel(CANVAS_W / 2, LOGO_TOP_MARGIN + 100);
} assert_ne!(center.0, BG.0, "default mark should paint over the field");
}
#[test]
fn webp_or_jpeg_input_is_accepted_and_transcoded_to_png() {
// Encode a JPEG and confirm the compositor decodes it and emits
// a valid PNG (iPXE only eats PNG, so transcoding is the point).
let img: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_pixel(80, 80, Rgb([90, 90, 90]));
let mut jpeg = Vec::new();
DynamicImage::ImageRgb8(img)
.write_to(&mut Cursor::new(&mut jpeg), ImageFormat::Jpeg)
.unwrap();
let out = compose_pxe_background(Some(&jpeg)).unwrap();
// Output must be a PNG (magic bytes) of canvas size.
assert_eq!(&out[..8], b"\x89PNG\r\n\x1a\n");
let img = image::load_from_memory(&out).unwrap();
assert_eq!(img.width(), CANVAS_W);
} }
#[test] #[test]
fn unsupported_bytes_returns_error_not_panic() { fn unsupported_bytes_returns_error_not_panic() {
let r = compose_pxe_logo(b"\xde\xad\xbe\xef not an image"); let r = compose_pxe_background(Some(b"\xde\xad\xbe\xef not an image"));
assert!(r.is_err()); assert!(r.is_err());
} }
#[test]
fn gradient_endpoints_match_stops() {
let stops = [[0, 0, 0], [255, 255, 255]];
assert_eq!(gradient_at(&stops, 0.0), [0, 0, 0]);
assert_eq!(gradient_at(&stops, 1.0), [255, 255, 255]);
}
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+87 -65
View File
@@ -15,18 +15,42 @@ use tokio::io::AsyncWriteExt;
/// Where the bytes for an ISO actually live. /// Where the bytes for an ISO actually live.
/// ///
/// The default is `Local` — uploaded ISOs sit in `<iso_dir>/<id>.iso`. /// `Local` — uploaded ISO, sits at `<iso_dir>/<id>.iso`.
/// `Nfs` entries point at a file inside a remote share that the /// `Smb` (v0.4.65) — remote SMB share, streamed via Samba's
/// `NfsManager` is keeping mounted. We resolve the on-disk path lazily /// userspace `smbclient` CLI subprocess. No kernel mount, no local
/// in [`IsoStore::iso_path_for`] using the `nfs_root` set at startup. /// cache. Sequential whole-file streaming; HTTP Range requests
/// return 416.
/// `Nfs` (v0.4.67) — remote NFSv3 share, streamed via the pure-Rust
/// `nfs3_client` crate (in-process, no subprocess). Same "works in
/// any container" property as SMB, plus Range requests work because
/// NFSv3 READ3 takes an explicit offset.
/// `Sftp` (v0.5.5) — remote SFTP-over-SSH share, streamed via the
/// pure-Rust `russh` + `russh-sftp` crates (in-process). Like NFS it
/// supports HTTP Range requests because SFTP opens a seekable file
/// handle (`SSH_FXP_READ` at offset).
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum IsoSource { pub enum IsoSource {
#[default] #[default]
Local, Local,
/// v0.4.65: SMB via userspace `smbclient` works in any container.
Smb {
share_id: String,
/// Filename at the share root. We don't support nested paths
/// in v0.4.65; ISOs live at the top of the share.
relative_path: String,
},
/// v0.4.67: NFSv3 via the in-process `nfs3_client` crate.
Nfs { Nfs {
mount_id: String, share_id: String,
/// Path relative to the mount point — typically just the filename. /// Filename at the export root.
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, relative_path: String,
}, },
} }
@@ -164,10 +188,6 @@ struct Inner {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct IsoStore { pub struct IsoStore {
iso_dir: Arc<PathBuf>, iso_dir: Arc<PathBuf>,
/// Where NFS mounts land on disk. Set at startup via
/// [`IsoStore::set_nfs_root`]; required for resolving any
/// `IsoSource::Nfs` entry.
nfs_root: Arc<RwLock<Option<PathBuf>>>,
inner: Arc<RwLock<Inner>>, inner: Arc<RwLock<Inner>>,
} }
@@ -175,17 +195,10 @@ impl IsoStore {
pub fn new(iso_dir: PathBuf) -> Self { pub fn new(iso_dir: PathBuf) -> Self {
Self { Self {
iso_dir: Arc::new(iso_dir), iso_dir: Arc::new(iso_dir),
nfs_root: Arc::new(RwLock::new(None)),
inner: Arc::new(RwLock::new(Inner::default())), inner: Arc::new(RwLock::new(Inner::default())),
} }
} }
/// Tell the store where NFS mounts live. Without this set,
/// `IsoSource::Nfs` entries cannot be resolved to a file path.
pub fn set_nfs_root(&self, root: PathBuf) {
*self.nfs_root.write() = Some(root);
}
pub async fn ensure_dirs(&self) -> Result<()> { pub async fn ensure_dirs(&self) -> Result<()> {
tokio::fs::create_dir_all(self.iso_dir.as_path()).await?; tokio::fs::create_dir_all(self.iso_dir.as_path()).await?;
Ok(()) Ok(())
@@ -281,33 +294,33 @@ impl IsoStore {
self.inner.read().isos.get(id).cloned() self.inner.read().isos.get(id).cloned()
} }
/// Resolve an ISO id to its on-disk path, if any. For local entries /// Resolve an ISO id to its on-disk path, if any. For local
/// this is `<iso_dir>/<id>.iso`; for NFS entries it's /// (uploaded) ISOs this is `<iso_dir>/<id>.iso`. For SMB-sourced
/// `<nfs_root>/<mount_id>/<relative_path>`. Returns None if the file /// ISOs there is no on-disk path — the HTTP handler must stream
/// is missing or the source isn't resolvable (e.g. NFS share /// via `SmbShareManager::stream_iso` instead. Returns `None` for
/// unmounted). /// 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)?;
let path = match &meta.source { match &meta.source {
IsoSource::Local => self.iso_path(id), IsoSource::Local => {
IsoSource::Nfs { let path = self.iso_path(id);
mount_id, if path.exists() {
relative_path, Some(path)
} => { } else {
let root = self.nfs_root.read().clone()?; None
root.join(mount_id).join(relative_path) }
} }
}; // SMB, NFS, and SFTP sources have no local path — they're
if path.exists() { // streamed in-process. Callers must inspect the source
Some(path) // kind first and dispatch to the appropriate share
} else { // manager.
None IsoSource::Smb { .. } | IsoSource::Nfs { .. } | IsoSource::Sftp { .. } => None,
} }
} }
/// Delete an ISO and its sidecar metadata. Only acts on local ISOs; /// Delete an ISO and its sidecar metadata. Only acts on local
/// for NFS-backed ISOs the operator must remove the file from the /// (uploaded) ISOs; for SMB-backed ISOs the operator must remove
/// share or unmount the NFS share entirely. /// the file from the share or unregister the share entirely.
pub async fn delete(&self, id: &str) -> Result<()> { pub async fn delete(&self, id: &str) -> Result<()> {
let meta = self.get(id); let meta = self.get(id);
let is_local = matches!( let is_local = matches!(
@@ -324,10 +337,10 @@ impl IsoStore {
Ok(()) Ok(())
} }
/// Register an externally-sourced ISO (e.g. NFS-mounted). Used by /// Register an externally-sourced ISO (SMB share, etc.). Used by
/// `NfsManager` after walking a freshly-mounted share. We do **not** /// `SmbShareManager` after listing a share. We do **not** persist
/// persist a `meta.json` on disk for these — the source of truth is /// a `meta.json` on disk for these — the source of truth is the
/// the share itself, and the NFS manager re-scans on startup. /// share itself, and the manager re-scans on startup.
pub fn register_external( pub fn register_external(
&self, &self,
id: String, id: String,
@@ -352,14 +365,20 @@ impl IsoStore {
self.inner.write().isos.insert(id, meta); self.inner.write().isos.insert(id, meta);
} }
/// Drop every entry that belongs to `mount_id`. Used by the NFS /// Drop every entry that belongs to `share_id`. Used by the SMB
/// manager when an operator removes a share, or before re-scanning /// and NFS share managers when an operator removes a share, or
/// to clean out stale entries. /// before re-scanning to clean out stale entries. The same id
pub fn drop_external_source(&self, mount_id: &str) { /// space serves both protocols — share ids are slugified from
/// `server+share` (SMB) or `server+export` (NFS) and the
/// protocol-specific prefix prevents collisions.
pub fn drop_external_source(&self, share_id: &str) {
let mut g = self.inner.write(); let mut g = self.inner.write();
g.isos.retain( g.isos.retain(|_, m| match &m.source {
|_, m| !matches!(&m.source, IsoSource::Nfs { mount_id: mid, .. } if mid == mount_id), IsoSource::Smb { share_id: sid, .. }
); | IsoSource::Nfs { share_id: sid, .. }
| IsoSource::Sftp { share_id: sid, .. } => sid != share_id,
IsoSource::Local => true,
});
} }
/// Set or clear an ISO's boot password. /// Set or clear an ISO's boot password.
@@ -538,22 +557,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")),
],
}, },
}] }]
} }
@@ -651,7 +670,10 @@ 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}");
+412
View File
@@ -0,0 +1,412 @@
//! Unattended-install answer-file store (v0.5.2).
//!
//! Operators upload the answer file their installer expects — a RHEL/
//! Fedora **Kickstart**, a Debian **Preseed**, an Ubuntu **Autoinstall**
//! cloud-init user-data, or a Windows **answer file** (`autounattend.xml`)
//! — and OpenPXE serves it on demand to the booting machine. Files live
//! in their own directory (`<unattended_dir>/`), deliberately *not* under
//! `iso_dir`, so they never appear in the ISO listing or the PXE menu.
//!
//! Storage mirrors [`crate::store::IsoStore`]: in-memory map authoritative
//! for the process, sidecar `*.meta.json` on disk is the source of truth on
//! restart. The raw answer file sits beside it as `<id>.file`.
//!
//! Templating is applied at *serve* time, not store time — see
//! [`render_template`]. The stored bytes are exactly what the operator
//! uploaded; per-host hostname/IP/MAC values are substituted into a copy
//! when the file is fetched for a specific client.
use crate::store::slugify_str;
use openpxe_core::{Error, Result};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use time::OffsetDateTime;
/// Disk + memory cap for one answer file. Kickstarts/preseeds/cloud-init
/// configs are a few KB; 1 MiB is a comfortable ceiling that still bounds
/// abuse.
pub const MAX_UNATTENDED_BYTES: usize = 1024 * 1024;
/// Which installer the answer file targets. Drives the kernel-argument
/// injection in the boot chain.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UnattendedKind {
/// RHEL / Fedora / CentOS / AlmaLinux / Rocky — `inst.ks=<url>`.
Kickstart,
/// Debian / older Ubuntu — `auto=true priority=critical url=<url>`.
Preseed,
/// Ubuntu 20.04+ Subiquity autoinstall — cloud-init NoCloud:
/// `autoinstall ds=nocloud-net;s=<url>/`.
Autoinstall,
/// Windows Setup answer file (`autounattend.xml`). Served, not
/// auto-injected (Windows reads it from media/USB, not a kernel arg).
AnswerFile,
/// Couldn't classify — stored + served, no auto-injection.
#[default]
Unknown,
}
impl UnattendedKind {
#[must_use]
pub fn label(self) -> &'static str {
match self {
UnattendedKind::Kickstart => "Kickstart",
UnattendedKind::Preseed => "Preseed",
UnattendedKind::Autoinstall => "Autoinstall",
UnattendedKind::AnswerFile => "Answer file",
UnattendedKind::Unknown => "Unknown",
}
}
}
/// Lowercase file extension (no dot), or `None` if there isn't one.
fn ext_lower(filename: &str) -> Option<String> {
std::path::Path::new(filename)
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
}
/// Classify an upload from its filename + a peek at its content. Best
/// effort: extension first, then a content sniff to disambiguate the
/// `.cfg` case (both Kickstart and Preseed use it).
#[must_use]
pub fn classify(filename: &str, content: &[u8]) -> UnattendedKind {
let lower_name = filename.to_ascii_lowercase();
let ext = ext_lower(filename);
let text = String::from_utf8_lossy(&content[..content.len().min(8192)]);
let looks_preseed = text.contains("d-i ") || text.contains("preseed/");
let looks_kickstart = text.contains("%packages")
|| text.contains("\nlang ")
|| text.contains("\nkeyboard ")
|| text.contains("bootloader --")
|| text.starts_with("install");
let looks_cloud_init = text.contains("autoinstall")
|| text.contains("#cloud-config")
|| text.contains("version: 1");
match ext.as_deref() {
Some("ks") => return UnattendedKind::Kickstart,
Some("seed") => return UnattendedKind::Preseed,
Some("xml") => return UnattendedKind::AnswerFile,
Some("yaml" | "yml") => return UnattendedKind::Autoinstall,
Some("cfg") => {
return if looks_kickstart && !looks_preseed {
UnattendedKind::Kickstart
} else {
UnattendedKind::Preseed
};
}
_ => {}
}
if lower_name == "user-data" {
return UnattendedKind::Autoinstall;
}
// No recognised extension — fall back to content sniffing.
if looks_cloud_init {
UnattendedKind::Autoinstall
} else if looks_kickstart {
UnattendedKind::Kickstart
} else if looks_preseed {
UnattendedKind::Preseed
} else {
UnattendedKind::Unknown
}
}
/// True if the filename carries an extension we accept for upload. We
/// also accept the bare `user-data` name (cloud-init NoCloud convention).
#[must_use]
pub fn is_accepted_filename(filename: &str) -> bool {
if filename.trim().eq_ignore_ascii_case("user-data") {
return true;
}
matches!(
ext_lower(filename).as_deref(),
Some("ks" | "cfg" | "seed" | "yaml" | "yml" | "xml")
)
}
/// Sidecar metadata for a stored answer file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnattendedMeta {
/// URL-safe slug, unique within the store.
pub id: String,
/// Original upload filename, shown in the UI.
pub filename: String,
pub kind: UnattendedKind,
pub size_bytes: u64,
#[serde(with = "time::serde::rfc3339")]
pub uploaded_at: OffsetDateTime,
}
#[derive(Debug, Default)]
struct Inner {
files: HashMap<String, UnattendedMeta>,
}
/// In-memory + on-disk answer-file registry. Cheap to clone.
#[derive(Debug, Clone)]
pub struct UnattendedStore {
dir: Arc<PathBuf>,
inner: Arc<RwLock<Inner>>,
}
impl UnattendedStore {
#[must_use]
pub fn new(dir: PathBuf) -> Self {
Self {
dir: Arc::new(dir),
inner: Arc::new(RwLock::new(Inner::default())),
}
}
pub async fn ensure_dir(&self) -> Result<()> {
tokio::fs::create_dir_all(self.dir.as_path()).await?;
Ok(())
}
/// Scan the directory on startup, loading every `*.meta.json` sidecar.
pub async fn load_from_disk(&self) -> Result<()> {
self.ensure_dir().await?;
let mut entries = tokio::fs::read_dir(self.dir.as_path()).await?;
while let Some(e) = entries.next_entry().await? {
let p = e.path();
let is_meta = p
.file_name()
.and_then(|s| s.to_str())
.is_some_and(|n| n.ends_with(".meta.json"));
if !is_meta {
continue;
}
if let Ok(text) = tokio::fs::read_to_string(&p).await {
if let Ok(meta) = serde_json::from_str::<UnattendedMeta>(&text) {
self.inner.write().files.insert(meta.id.clone(), meta);
}
}
}
Ok(())
}
fn data_path(&self, id: &str) -> PathBuf {
self.dir.join(format!("{id}.file"))
}
fn meta_path(&self, id: &str) -> PathBuf {
self.dir.join(format!("{id}.meta.json"))
}
/// Mint a unique slug from the upload filename's stem.
fn unique_id(&self, filename: &str) -> String {
let stem = filename.rsplit_once('.').map_or(filename, |(s, _)| s);
let base = {
let s = slugify_str(stem);
if s.is_empty() {
"unattended".to_string()
} else {
s
}
};
let g = self.inner.read();
if !g.files.contains_key(&base) {
return base;
}
for n in 1.. {
let candidate = format!("{base}-{n}");
if !g.files.contains_key(&candidate) {
return candidate;
}
}
unreachable!("u64 ids exhausted")
}
/// Store an uploaded answer file. Validates type + size, classifies,
/// writes the bytes + a sidecar, and returns the new metadata.
pub async fn add(&self, filename: &str, bytes: &[u8]) -> Result<UnattendedMeta> {
if !is_accepted_filename(filename) {
return Err(Error::Invalid(format!(
"unsupported answer-file type '{filename}'. Accepted: .ks, .cfg, .seed, .yaml, .yml, .xml, user-data"
)));
}
if bytes.len() > MAX_UNATTENDED_BYTES {
return Err(Error::Invalid(format!(
"answer file too large ({} bytes, max {MAX_UNATTENDED_BYTES})",
bytes.len()
)));
}
self.ensure_dir().await?;
let kind = classify(filename, bytes);
let id = self.unique_id(filename);
let meta = UnattendedMeta {
id: id.clone(),
filename: filename.to_string(),
kind,
size_bytes: bytes.len() as u64,
uploaded_at: OffsetDateTime::now_utc(),
};
// Atomic data write: tmp -> rename.
let data = self.data_path(&id);
let tmp = data.with_extension("file.tmp");
tokio::fs::write(&tmp, bytes).await?;
tokio::fs::rename(&tmp, &data).await?;
let meta_text = serde_json::to_string_pretty(&meta).map_err(|e| Error::Other(e.into()))?;
tokio::fs::write(self.meta_path(&id), meta_text).await?;
self.inner.write().files.insert(id.clone(), meta.clone());
tracing::info!(
target: "openpxe::unattended",
id = %id, file = %filename, kind = ?kind, size = bytes.len(),
"unattended answer file stored"
);
Ok(meta)
}
#[must_use]
pub fn list(&self) -> Vec<UnattendedMeta> {
let g = self.inner.read();
let mut v: Vec<_> = g.files.values().cloned().collect();
v.sort_by_key(|m| std::cmp::Reverse(m.uploaded_at));
v
}
#[must_use]
pub fn get(&self, id: &str) -> Option<UnattendedMeta> {
self.inner.read().files.get(id).cloned()
}
/// Read the raw stored bytes for `id`.
pub async fn read(&self, id: &str) -> Result<Vec<u8>> {
if !self.inner.read().files.contains_key(id) {
return Err(Error::NotFound(format!("no unattended file '{id}'")));
}
let bytes = tokio::fs::read(self.data_path(id)).await?;
Ok(bytes)
}
/// Remove a file + its sidecar. Returns true if something was removed.
pub async fn remove(&self, id: &str) -> bool {
let existed = self.inner.write().files.remove(id).is_some();
if existed {
let _ = tokio::fs::remove_file(self.data_path(id)).await;
let _ = tokio::fs::remove_file(self.meta_path(id)).await;
}
existed
}
#[must_use]
pub fn len(&self) -> usize {
self.inner.read().files.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
/// Substitute the per-host template tokens into an answer file at serve
/// time. Recognised tokens (case-sensitive, double-brace): `{{HOSTNAME}}`,
/// `{{IP}}`, `{{MAC}}`. Unset values render as an empty string so a
/// half-filled profile never leaves a literal `{{IP}}` in the file.
#[must_use]
pub fn render_template(
content: &str,
mac: Option<&str>,
hostname: Option<&str>,
ip: Option<&str>,
) -> String {
content
.replace("{{HOSTNAME}}", hostname.unwrap_or(""))
.replace("{{IP}}", ip.unwrap_or(""))
.replace("{{MAC}}", mac.unwrap_or(""))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn classify_by_extension() {
assert_eq!(
classify(" subiquity.yaml", b""),
UnattendedKind::Autoinstall
);
assert_eq!(classify("ks.ks", b""), UnattendedKind::Kickstart);
assert_eq!(classify("preseed.seed", b""), UnattendedKind::Preseed);
assert_eq!(
classify("autounattend.xml", b"<xml/>"),
UnattendedKind::AnswerFile
);
assert_eq!(classify("user-data", b""), UnattendedKind::Autoinstall);
}
#[test]
fn classify_cfg_by_content() {
assert_eq!(
classify("answer.cfg", b"d-i debian-installer/locale string en_US"),
UnattendedKind::Preseed
);
assert_eq!(
classify("answer.cfg", b"install\n%packages\n@core\n%end\n"),
UnattendedKind::Kickstart
);
}
#[test]
fn accepted_filenames() {
assert!(is_accepted_filename("a.ks"));
assert!(is_accepted_filename("USER-DATA".to_lowercase().as_str()));
assert!(is_accepted_filename("autounattend.XML"));
assert!(!is_accepted_filename("evil.sh"));
assert!(!is_accepted_filename("image.iso"));
}
#[test]
fn template_substitutes_and_blanks_unset() {
let body = "ip={{IP}} host={{HOSTNAME}} mac={{MAC}}";
let out = render_template(body, Some("aa:bb"), Some("node1"), None);
assert_eq!(out, "ip= host=node1 mac=aa:bb");
}
#[tokio::test]
async fn add_list_read_remove_round_trip() {
let dir = tempdir().unwrap();
let s = UnattendedStore::new(dir.path().join("unattended"));
let meta = s
.add("rocky.ks", b"install\n%packages\n@core\n%end\n")
.await
.unwrap();
assert_eq!(meta.kind, UnattendedKind::Kickstart);
assert_eq!(s.len(), 1);
let got = s.read(&meta.id).await.unwrap();
assert!(got.starts_with(b"install"));
// Survives a reload.
let s2 = UnattendedStore::new(dir.path().join("unattended"));
s2.load_from_disk().await.unwrap();
assert!(s2.get(&meta.id).is_some());
assert!(s2.remove(&meta.id).await);
assert!(s2.get(&meta.id).is_none());
}
#[tokio::test]
async fn rejects_bad_type_and_oversize() {
let dir = tempdir().unwrap();
let s = UnattendedStore::new(dir.path().join("unattended"));
assert!(s.add("evil.sh", b"#!/bin/sh").await.is_err());
let big = vec![b'x'; MAX_UNATTENDED_BYTES + 1];
assert!(s.add("big.ks", &big).await.is_err());
}
#[tokio::test]
async fn ids_are_unique() {
let dir = tempdir().unwrap();
let s = UnattendedStore::new(dir.path().join("unattended"));
let a = s.add("ks.ks", b"install").await.unwrap();
let b = s.add("ks.ks", b"install").await.unwrap();
assert_ne!(a.id, b.id);
}
}
+102 -15
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, NfsManager, SmbManager}; 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,10 +94,25 @@ 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/
// Windows answer files). Separate directory from the ISO store.
let unattended = openpxe_iso_store::UnattendedStore::new(config.paths.unattended_dir.clone());
if let Err(e) = unattended.load_from_disk().await {
tracing::warn!(
target: "openpxe::unattended",
"could not load unattended files on startup: {e}"
);
}
let clients = ClientRegistry::new(); let clients = ClientRegistry::new();
let queue = DeploymentQueue::new(); let queue = DeploymentQueue::new();
let settings = SettingsStore::load_or_default(&config.paths.work_dir); let settings = SettingsStore::load_or_default(&config.paths.work_dir);
@@ -107,6 +121,7 @@ async fn main() -> anyhow::Result<()> {
let branding = openpxe_core::BrandingStore::load_or_default(&config.paths.work_dir); let branding = openpxe_core::BrandingStore::load_or_default(&config.paths.work_dir);
let admin = openpxe_core::AdminStore::load_or_default(&config.paths.work_dir); let admin = openpxe_core::AdminStore::load_or_default(&config.paths.work_dir);
let sso = openpxe_core::SsoStore::load_or_default(&config.paths.work_dir); let sso = openpxe_core::SsoStore::load_or_default(&config.paths.work_dir);
let notify = openpxe_core::NotifyStore::load_or_default(&config.paths.work_dir);
let sessions = openpxe_http_api::auth::SessionStore::default(); let sessions = openpxe_http_api::auth::SessionStore::default();
let metrics = Metrics::new(); let metrics = Metrics::new();
@@ -119,13 +134,43 @@ async fn main() -> anyhow::Result<()> {
let _ = smb.start(); let _ = smb.start();
} }
// NFS manager. The mount root has to be set on the IsoStore *before* // v0.4.65: SMB share manager — Samba `smbclient` userspace
// we replay any persisted mounts, otherwise an in-memory IsoMeta // consumer. Replaces the kernel-mount NFS path that v0.4.64
// pointing at an NFS source can't resolve to a path. // shipped; that didn't work on hosts whose kernel lacked the nfs
let nfs = NfsManager::new(&config.paths.work_dir, iso_store.clone()); // client modules (Unraid is the dominant case). `smbclient` does
iso_store.set_nfs_root(nfs.mount_root()); // the SMB protocol entirely in userspace over TCP and works in
if let Err(e) = nfs.load_and_remount().await { // any container regardless of capabilities or kernel modules.
tracing::warn!(target: "openpxe::nfs", "could not reload NFS mounts: {e}"); let smb_shares = SmbShareManager::new(&config.paths.work_dir, iso_store.clone());
if let Err(e) = smb_shares.load_and_rescan().await {
tracing::warn!(
target: "openpxe::smb",
"could not reload SMB shares on startup: {e}"
);
}
// v0.4.67: NFSv3 share manager — pure-Rust in-process consumer
// via the `nfs3_client` crate. Sits alongside the SMB manager;
// operators pick whichever protocol their NAS prefers, or use
// both. No subprocess, no kernel mount, works in any container.
let nfs_shares = NfsShareManager::new(&config.paths.work_dir, iso_store.clone());
if let Err(e) = nfs_shares.load_and_rescan().await {
tracing::warn!(
target: "openpxe::nfs",
"could not reload NFS shares on startup: {e}"
);
}
// v0.5.5: SFTP-over-SSH share manager — pure-Rust in-process
// consumer via `russh` + `russh-sftp` (ring backend, no OpenSSL).
// The third remote-library protocol alongside SMB/NFS; like NFS it
// works in any container (no subprocess, no kernel mount) and
// supports HTTP Range requests because SFTP file handles seek.
let sftp_shares = SftpShareManager::new(&config.paths.work_dir, iso_store.clone());
if let Err(e) = sftp_shares.load_and_rescan().await {
tracing::warn!(
target: "openpxe::sftp",
"could not reload SFTP shares on startup: {e}"
);
} }
// Sniff network details for the Network tab. None of these are // Sniff network details for the Network tab. None of these are
@@ -150,9 +195,14 @@ async fn main() -> anyhow::Result<()> {
admin: admin.clone(), admin: admin.clone(),
sessions: sessions.clone(), sessions: sessions.clone(),
sso: sso.clone(), sso: sso.clone(),
saml: openpxe_http_api::saml_routes::SamlRuntime::default(),
notify: notify.clone(),
metrics: metrics.clone(), metrics: metrics.clone(),
smb: Some(smb.clone()), smb: Some(smb.clone()),
nfs: nfs.clone(), smb_shares: smb_shares.clone(),
nfs_shares: nfs_shares.clone(),
sftp_shares: sftp_shares.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(),
@@ -301,6 +351,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`
@@ -434,3 +498,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");
}
}
+197 -1
View File
@@ -34,9 +34,18 @@
--topbar-h: 56px; --topbar-h: 56px;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
/* v0.4.63: tie native form-control rendering (checkboxes, scroll bars,
date pickers) to the active OpenPXE theme. Without this, the inline
`<meta name="color-scheme" content="dark light">` in index.html forces
dark form chrome in *both* themes so the SSO "Enable single sign-on"
checkbox renders as an opaque black square against the light-mode
panel, ignoring our accent-color hint. CSS `color-scheme` overrides
the meta and tracks `data-theme` correctly. */
color-scheme: dark;
} }
:root[data-theme="light"] { :root[data-theme="light"] {
color-scheme: light;
/* Light palette high-contrast neutral, accent unchanged for brand /* Light palette high-contrast neutral, accent unchanged for brand
consistency. Designed against Netbox Labs's reference screenshot: consistency. Designed against Netbox Labs's reference screenshot:
near-white surfaces, soft grey dividers, dark text. */ near-white surfaces, soft grey dividers, dark text. */
@@ -108,6 +117,20 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
letter-spacing: 0.2px; letter-spacing: 0.2px;
color: var(--fg); color: var(--fg);
} }
/* v0.4.69: FleetDM-style full-width custom logo. When the operator has
uploaded a custom brand mark, the sidebar header drops the bundled
26px mark + "OpenPXE" wordmark and instead lets the uploaded image
span the header left-aligned, capped at 200x50, scaled to fit
without distortion. The wordmark is hidden so the operator's logo is
the sole brand element (their logo presumably already contains their
name). The bundled-default case keeps the mark + wordmark. */
.sidebar .brand.has-custom-logo { gap: 0; }
.sidebar .brand.has-custom-logo img {
width: auto; height: 50px; max-width: 200px;
object-fit: contain; object-position: left center; flex: none;
}
.sidebar .brand.has-custom-logo strong { display: none; }
.sidebar nav { padding: 10px 0; flex: 1; overflow-y: auto; } .sidebar nav { padding: 10px 0; flex: 1; overflow-y: auto; }
.sidebar nav a { .sidebar nav a {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
@@ -281,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;
@@ -304,6 +335,23 @@ label.field textarea {
font-size: 14px; line-height: 1.4; font-size: 14px; line-height: 1.4;
box-shadow: none; -webkit-appearance: none; appearance: none; box-shadow: none; -webkit-appearance: none; appearance: none;
} }
/* v0.4.63: with `appearance: none`, the native <select> dropdown arrow
disappears, which makes the "Metadata source" pick-list look like a
plain (and slightly squished) text input. Paint our own chevron via
background-image so the control still reads as a dropdown, and reserve
right-padding for it. The data-URI SVG inherits currentColor via the
`stroke` attribute so the arrow follows light/dark theme without a
second declaration. */
label.field select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%239aa0a6' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><polyline points='1.5,1.5 6,6 10.5,1.5'/></svg>");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 11px 7px;
padding-right: 30px;
}
:root[data-theme="light"] label.field select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%235a6377' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><polyline points='1.5,1.5 6,6 10.5,1.5'/></svg>");
}
label.field input:focus, label.field select:focus, label.field textarea:focus { label.field input:focus, label.field select:focus, label.field textarea:focus {
outline: none; border-color: var(--accent); outline: none; border-color: var(--accent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
@@ -313,7 +361,40 @@ label.check {
padding: 8px 10px; margin-bottom: 6px; padding: 8px 10px; margin-bottom: 6px;
border: 1px solid var(--border-soft); border-radius: var(--radius); border: 1px solid var(--border-soft); border-radius: var(--radius);
} }
label.check input { accent-color: var(--accent); } /* v0.4.63: native checkboxes used to render as opaque black squares in
light mode because the page meta declares `color-scheme: dark light`
and `accent-color` alone only repaints the *check mark* (not the
container). Take full control of the chrome so the box reads cleanly
on both palettes and the checked state lights up in our accent. */
label.check input[type="checkbox"] {
appearance: none; -webkit-appearance: none;
width: 16px; height: 16px; flex: none;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
display: inline-grid; place-content: center;
cursor: pointer; margin: 0;
transition: background 0.1s ease, border-color 0.1s ease;
}
label.check input[type="checkbox"]:hover { border-color: var(--accent); }
label.check input[type="checkbox"]:checked {
background: var(--accent);
border-color: var(--accent);
}
label.check input[type="checkbox"]:checked::after {
/* Classic glyph built from a rotated rectangle border. Colour is
#002923 (the same near-black we use on solid-accent buttons) so the
tick stays legible against the teal fill in both themes. */
content: '';
width: 4px; height: 8px;
border: solid #002923;
border-width: 0 2px 2px 0;
transform: rotate(45deg) translate(-1px, -1px);
}
label.check input[type="checkbox"]:focus-visible {
outline: none;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
}
/* ── Drop zone ────────────────────────────────────────────────────── */ /* ── Drop zone ────────────────────────────────────────────────────── */
@@ -554,6 +635,14 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
} }
.auth-card .brand-row img { width: 32px; height: 32px; flex: none; } .auth-card .brand-row img { width: 32px; height: 32px; flex: none; }
.auth-card .brand-row .name { font-size: 17px; font-weight: 600; letter-spacing: 0.2px; color: var(--fg); } .auth-card .brand-row .name { font-size: 17px; font-weight: 600; letter-spacing: 0.2px; color: var(--fg); }
/* v0.5.0: FleetDM-style custom logo on the login/setup card the
uploaded logo spans the card header and the "OpenPXE" wordmark is
dropped (the logo is the brand). Matches the sidebar treatment. */
.auth-card .brand-row.has-custom-logo { justify-content: center; gap: 0; margin-bottom: 22px; }
.auth-card .brand-row.has-custom-logo img {
width: auto; height: 52px; max-width: 240px;
object-fit: contain; object-position: center;
}
.auth-card h2 { .auth-card h2 {
margin: 0 0 6px; font-size: 16px; font-weight: 600; color: var(--fg); margin: 0 0 6px; font-size: 16px; font-weight: 600; color: var(--fg);
} }
@@ -716,3 +805,110 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
.logo-preview .info { flex: 1; min-width: 0; } .logo-preview .info { flex: 1; min-width: 0; }
.logo-preview .info .name { color: var(--fg); font-weight: 600; } .logo-preview .info .name { color: var(--fg); font-weight: 600; }
.logo-preview .info .meta { color: var(--fg-dim); font-size: 12px; margin-top: 2px; } .logo-preview .info .meta { color: var(--fg-dim); font-size: 12px; margin-top: 2px; }
/* v0.5.1: collapsible "Advanced" disclosure at the bottom of Settings
(the former Advanced sidebar tab). A quiet, full-width toggle that
expands to reveal the notification + API-reference cards. */
.advanced-disclosure { width: 100%; }
.advanced-summary {
list-style: none;
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
color: var(--fg-dim);
font-size: 13px;
font-weight: 600;
background: var(--bg-panel-2);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.advanced-summary:hover { color: var(--fg); }
.advanced-summary::-webkit-details-marker { display: none; }
.advanced-summary::before {
content: "▸";
font-size: 11px;
transition: transform 0.15s ease;
}
.advanced-disclosure[open] .advanced-summary::before { transform: rotate(90deg); }
/* v0.5.1: protocol tag on a unified Remote-shares row (SMB / NFS). */
.proto-badge {
display: inline-block;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
padding: 1px 6px;
margin-right: 8px;
border-radius: 4px;
vertical-align: middle;
background: var(--bg-panel-2);
border: 1px solid var(--border);
color: var(--fg-dim);
}
/* ── v0.5.2: three-slot branding (light / dark / client) ─────────── */
.logo-slots {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
@media (max-width: 720px) { .logo-slots { grid-template-columns: 1fr; } }
.logo-slot {
display: flex; flex-direction: column; gap: 8px;
padding: 12px;
background: var(--bg-panel-2);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.logo-slot-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.logo-slot-head .name { color: var(--fg); font-weight: 600; font-size: 13px; }
.logo-slot .swatch {
height: 64px;
display: flex; align-items: center; justify-content: center;
background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius);
}
.logo-slot .swatch img { max-width: 90%; max-height: 52px; object-fit: contain; }
.logo-slot-hint { color: var(--fg-dim); font-size: 11.5px; }
/* ── v0.5.2: login local/SSO separation ─────────────────────────── */
.auth-card .auth-divider {
display: flex; align-items: center; text-align: center;
color: var(--fg-dimmer); font-size: 11px; text-transform: uppercase;
letter-spacing: 0.08em;
margin: 16px 0 12px;
}
.auth-card .auth-divider::before,
.auth-card .auth-divider::after {
content: ""; flex: 1; height: 1px; background: var(--border-soft);
}
.auth-card .auth-divider span { padding: 0 10px; }
.auth-card .sso-block .sso-btn { margin-top: 0; }
.auth-card .sso-btn {
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.auth-card .sso-btn .sso-logo { width: 16px; height: 16px; object-fit: contain; flex: none; }
/* ── v0.5.2: modal (queue Profile editor) ───────────────────────── */
.modal-overlay {
position: fixed; inset: 0; z-index: 200;
display: flex; align-items: center; justify-content: center;
background: rgba(0, 0, 0, 0.55);
padding: 24px;
}
.modal-box {
width: 100%; max-width: 520px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 22px;
}
.modal-box h2 { margin: 0 0 14px; font-size: 16px; font-weight: 600; color: var(--fg); }
.modal-actions {
display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px;
}
.modal-actions .submit { width: auto; padding: 8px 18px; }
+1071 -266
View File
File diff suppressed because it is too large Load Diff
+7 -4
View File
@@ -13,7 +13,10 @@
on the asset handlers, the practical caching window is one on the asset handlers, the practical caching window is one
version. --> version. -->
<link rel="stylesheet" href="/assets/app.css?v={{ASSET_VERSION}}" /> <link rel="stylesheet" href="/assets/app.css?v={{ASSET_VERSION}}" />
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg?v={{ASSET_VERSION}}" /> <!-- v0.5.2: favicon is pinned to the bundled OpenPXE mark (its own
endpoint, decoupled from operator branding) for tab-icon
continuity regardless of any uploaded light/dark/client logo. -->
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg?v={{ASSET_VERSION}}" />
<!-- Theme is read from localStorage *before* paint to avoid the <!-- Theme is read from localStorage *before* paint to avoid the
dark→light flash on every navigation. Falls back to the OS dark→light flash on every navigation. Falls back to the OS
preference and finally to dark. --> preference and finally to dark. -->
@@ -32,8 +35,8 @@
<body> <body>
<div class="shell"> <div class="shell">
<aside class="sidebar"> <aside class="sidebar">
<div class="brand"> <div class="{{BRAND_CLASS}}">
<img src="/assets/logo.svg?v={{ASSET_VERSION}}" alt="OpenPXE" /> <img src="/assets/logo.svg?v={{ASSET_VERSION}}&r={{LOGO_REV}}" alt="OpenPXE" />
<strong>OpenPXE</strong> <strong>OpenPXE</strong>
</div> </div>
<nav> <nav>
@@ -66,7 +69,7 @@
<!-- The brand badge at the top can be overridden by operator-uploaded <!-- The brand badge at the top can be overridden by operator-uploaded
logos; keep "OpenPXE v…" pinned in the footer so the backend logos; keep "OpenPXE v…" pinned in the footer so the backend
identity is always visible regardless of branding. --> identity is always visible regardless of branding. -->
<div class="footer-version">OpenPXE&nbsp;v<span data-bind="version">0.4.61</span></div> <div class="footer-version">OpenPXE&nbsp;v<span data-bind="version">0.4.63</span></div>
</div> </div>
</aside> </aside>
+24 -1
View File
@@ -17,11 +17,34 @@
/// when we know the new one is incompatible. Combined with /// when we know the new one is incompatible. Combined with
/// `Cache-Control: no-cache, must-revalidate` on the asset handlers, /// `Cache-Control: no-cache, must-revalidate` on the asset handlers,
/// the worst-case caching window is one version. /// the worst-case caching window is one version.
/// * `logo_rev` is appended to the brand-mark and favicon URLs as an
/// extra `&r=…` token. Unlike `asset_version` it changes every time
/// the operator swaps the custom logo, so the top-left mark updates
/// immediately on the next page load instead of being pinned to the
/// release version (which only changes on upgrade). `index.html`
/// itself is served `no-cache`, so the fresh token lands as soon as
/// the operator reloads after an upload.
/// * `has_custom_logo` switches the sidebar brand block between the
/// bundled mark + "OpenPXE" wordmark (false) and a FleetDM-style
/// full-width custom logo with the wordmark hidden (true). Rendered
/// server-side so there's no flash of the default mark before JS runs.
#[must_use] #[must_use]
pub fn index_html(base_url: &str, asset_version: &str) -> String { pub fn index_html(
base_url: &str,
asset_version: &str,
logo_rev: u64,
has_custom_logo: bool,
) -> String {
let brand_class = if has_custom_logo {
"brand has-custom-logo"
} else {
"brand"
};
INDEX_HTML INDEX_HTML
.replace("{{BASE_URL}}", base_url) .replace("{{BASE_URL}}", base_url)
.replace("{{ASSET_VERSION}}", asset_version) .replace("{{ASSET_VERSION}}", asset_version)
.replace("{{LOGO_REV}}", &logo_rev.to_string())
.replace("{{BRAND_CLASS}}", brand_class)
} }
#[must_use] #[must_use]
+93 -43
View File
@@ -17,14 +17,11 @@
ARG RUST_VERSION=1.95 ARG RUST_VERSION=1.95
########## fetch iPXE binaries + wimboot ########## ########## fetch iPXE binaries + wimboot ##########
# v0.4.62: kept on the boot.ipxe.org pre-builds for the moment. We # Pulls the upstream boot.ipxe.org pre-builds (no PNG support) plus
# want PNG support (so `console --picture` paints the operator's logo # wimboot. These cover the arches we don't build from source here:
# on the PXE menu) but the obvious path — adding a new `ipxe-build` # BIOS undionly.kpxe and i386-efi (which need a 32-bit x86 toolchain),
# stage that compiles iPXE from source with `IMAGE_PNG` enabled # and serve as the baseline that the PNG-enabled x86_64/arm64 UEFI
# runs into a QEMU/gcc instability when cross-emulating x86_64 on # binaries from the `ipxe-build` stage overlay on top of.
# arm64 build hosts (intermittent `cc1` segfaults). The compositor
# at /branding/pxe-logo is already wired so when the iPXE rebuild
# lands (on native x86_64 hardware), no other code change is needed.
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 \ RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -32,47 +29,88 @@ WORKDIR /src
COPY scripts/fetch-ipxe.sh scripts/fetch-ipxe.sh COPY scripts/fetch-ipxe.sh scripts/fetch-ipxe.sh
RUN mkdir -p assets/ipxe && bash scripts/fetch-ipxe.sh RUN mkdir -p assets/ipxe && bash scripts/fetch-ipxe.sh
########## build openpxe ########## ########## build PNG-enabled iPXE from source ##########
FROM rust:${RUST_VERSION}-bookworm AS build # v0.4.69: THE graphical-boot-menu unlock. iVentoy paints a PNG
WORKDIR /src # background on the PXE screen using stock iPXE built with
# CONSOLE_FRAMEBUFFER + IMAGE_PNG + CONSOLE_CMD; the public iPXE
# v0.4.5: build a fully static musl binary (matches Bootimus v0.1.70's # binaries omit those, so `console --picture` is a no-op on them.
# move). The resulting `/openpxe` has no glibc dependency at all, which: # We build our own from upstream with that thin config delta.
# - 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 # The historical blocker was cc1 segfaulting when an amd64 gcc ran
# RUSTFLAGS needed). musl-tools provides the linker. # under QEMU emulation on an arm64 host. The fix: pin this stage to
# $BUILDPLATFORM (the NATIVE builder arch — arm64 on an Apple-Silicon
# Mac, amd64 in x86 CI) and cross-compile with a real cross toolchain
# (CROSS_COMPILE=x86_64-linux-gnu-). The compiler runs native and
# emits x86_64 — no emulation, no segfault. arm64-efi builds natively.
FROM --platform=$BUILDPLATFORM debian:12-slim AS ipxe-build
# libc6-dev is REQUIRED and easy to miss under --no-install-recommends:
# iPXE's host utilities (elf2efi, zbin) compile with the native gcc and
# pull <stdint.h>; without the native libc headers gcc's #include_next
# falls through to iPXE's freestanding headers and dies on bits/stdint.h.
# The target (iPXE firmware) code is -ffreestanding/-nostdinc, so the
# x86_64 cross toolchain needs NO cross libc headers.
RUN apt-get update && apt-get install -y --no-install-recommends \
git make perl gcc binutils libc6-dev \
gcc-x86-64-linux-gnu binutils-x86-64-linux-gnu \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY scripts/build-ipxe.sh scripts/build-ipxe.sh
COPY deploy/ipxe/local/ deploy/ipxe/local/
RUN mkdir -p assets/ipxe && bash scripts/build-ipxe.sh /src/assets/ipxe
########## build openpxe ##########
# v0.5.2: cross-compile the Rust binary NATIVELY — no QEMU.
#
# This stage is pinned to $BUILDPLATFORM (the builder's native arch — arm64
# on an Apple-Silicon Mac, amd64 in x86 CI), exactly like `ipxe-build`. The
# Rust compiler therefore runs at full native speed and emits an
# x86_64-unknown-linux-musl binary via `cargo-zigbuild`, which uses `zig cc`
# as the cross-linker (it bundles the musl sysroot for every target, so
# there's no fiddly cross-gcc toolchain to assemble).
#
# Why this replaced the old `FROM rust ... --platform=linux/amd64` build:
# that ran the *entire* compiler under QEMU x86_64 emulation on the arm64
# host. It was ~15x slower (a single crate took >20 min) and the emulated
# gcc/linker intermittently SIGSEGV'd or hung mid-link. Cross-compiling
# sidesteps emulation entirely — the build is minutes, not half an hour,
# and is deterministic.
#
# The output is still a fully static musl binary with no glibc dependency,
# so the runtime stage stays free to be any Linux distro.
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-bookworm AS build
WORKDIR /src
# 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
# PNG-enabled x86_64 + arm64 UEFI binaries built from source. The
# overlay wins for snponly.efi / ipxe.efi / snponly-arm64.efi so the
# common modern clients get the graphical background; the rest keep the
# upstream no-PNG binaries and the menu's `|| console` text fallback.
COPY --from=fetch /src/assets/ipxe /src/assets/ipxe COPY --from=fetch /src/assets/ipxe /src/assets/ipxe
COPY --from=ipxe-build /src/assets/ipxe/snponly.efi /src/assets/ipxe/snponly.efi
COPY --from=ipxe-build /src/assets/ipxe/ipxe.efi /src/assets/ipxe/ipxe.efi
# Cache cargo registry + target across builds. 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
@@ -81,23 +119,30 @@ FROM debian:12-slim AS runtime
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
ca-certificates libcap2-bin tini gosu iproute2 \ ca-certificates libcap2-bin tini gosu iproute2 \
wimtools samba nfs-common \ wimtools samba smbclient \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10001 --home-dir /var/lib/openpxe --shell /usr/sbin/nologin openpxe \ && useradd --system --uid 10001 --home-dir /var/lib/openpxe --shell /usr/sbin/nologin openpxe \
&& mkdir -p /var/lib/openpxe/isos /var/lib/openpxe/work /var/lib/openpxe/smb \ && mkdir -p /var/lib/openpxe/isos /var/lib/openpxe/work /var/lib/openpxe/smb \
&& chown -R openpxe:openpxe /var/lib/openpxe && chown -R openpxe:openpxe /var/lib/openpxe
# v0.4.5: the openpxe binary itself is now built against musl and is # v0.4.5: the openpxe binary itself is now built against musl and is
# fully static — no glibc dependency. The runtime stage still ships # fully static — no glibc dependency. The runtime stage still ships
# Debian slim because OpenPXE shells out to the four packages below for # Debian slim because OpenPXE shells out to the packages below for
# functionality we deliberately don't reimplement in-process: # functionality we deliberately don't reimplement in-process:
#
# wimtools - `wimlib-imagex`, used to inject startnet.cmd into boot.wim. # wimtools - `wimlib-imagex`, used to inject startnet.cmd into boot.wim.
# samba - `smbd` serves extracted Windows install media on :445 so # samba - `smbd` serves extracted Windows install media on :445 so
# WinPE can `net use`. Guest read-only, scoped to # WinPE can `net use`. Guest read-only, scoped to
# /var/lib/openpxe/smb. # /var/lib/openpxe/smb. This package provides the SERVER
# nfs-common - `mount.nfs` / `mount.nfs4` for the Storage tab's NFS # side only; the client CLI is a separate package below.
# share manager. Mount requires CAP_SYS_ADMIN; without it # smbclient - v0.4.66: Samba's `smbclient` userspace CLI, used by
# mount(2) returns EPERM and the manager surfaces a clear # the Storage tab's SMB shares manager to list and stream
# error in the UI. # ISOs from remote SMB servers without ever mounting them
# in the kernel. In Debian 12 `smbclient` is NOT pulled
# in by the `samba` package — they're siblings, not
# parent/child. v0.4.65 shipped without this line and
# every "Add share" attempt surfaced
# `could not exec smbclient: No such file or directory`
# until this landed.
# iproute2 - `ip addr` / `ip route` for the auto-detected Network # iproute2 - `ip addr` / `ip route` for the auto-detected Network
# tab fields (NIC name, subnet mask, default gateway). # tab fields (NIC name, subnet mask, default gateway).
# Tiny, always available; we don't pull in netlink crates # Tiny, always available; we don't pull in netlink crates
@@ -105,8 +150,13 @@ RUN apt-get update \
# gosu - drops privileges cleanly from root after the entrypoint # gosu - drops privileges cleanly from root after the entrypoint
# fixes bind-mount ownership (common OpenShift/Docker UX # fixes bind-mount ownership (common OpenShift/Docker UX
# issue). # issue).
#
# v0.4.65 dropped `nfs-common` — kernel-mount NFS is gone. The SMB
# shares replacement uses userspace `smbclient` and needs no kernel
# helpers.
#
# A future "openpxe-static" variant could drop everything except the # A future "openpxe-static" variant could drop everything except the
# binary onto distroless once we move the Windows + NFS legs to # binary onto distroless once we move the Windows + SMB legs to
# in-process Rust crates. # in-process Rust crates.
COPY --from=build /openpxe /usr/local/bin/openpxe COPY --from=build /openpxe /usr/local/bin/openpxe
+14
View File
@@ -0,0 +1,14 @@
/*
* OpenPXE iPXE build override console options.
*
* Included at the end of config/console.h. CONSOLE_FRAMEBUFFER is the
* unified graphical framebuffer console (EFI GOP on UEFI, VESA on
* BIOS); it's what `console --picture` paints into. This is the same
* single flag iVentoy enables for its graphical PXE screen.
*
* We *add* the framebuffer console rather than replacing the default
* EFI/BIOS text consoles, so text output still works before/after the
* picture is set.
*/
#define CONSOLE_FRAMEBUFFER
+23
View File
@@ -0,0 +1,23 @@
/*
* OpenPXE iPXE build override general options.
*
* iPXE includes <config/local/general.h> at the end of config/general.h,
* so anything defined here is layered on top of the stock defaults
* without editing upstream files. We enable exactly the features the
* graphical PXE boot menu needs:
*
* IMAGE_PNG - PNG decoder, so `console --picture <png>` can paint
* the operator's logo / OpenPXE background.
* IMAGE_PNM - Netpbm decoder (cheap; harmless belt-and-suspenders).
* CONSOLE_CMD - the `console` command itself. Without it you get
* "console: command not found" even with a framebuffer.
*
* (CONSOLE_FRAMEBUFFER lives in config/local/console.h.)
*
* Everything else stays at upstream defaults we are intentionally a
* thin, auditable delta over stock iPXE so the UBDL/GPL story is simple.
*/
#define IMAGE_PNG
#define IMAGE_PNM
#define CONSOLE_CMD
+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

+106
View File
@@ -0,0 +1,106 @@
# PXE menu theme — research for next-release follow-up
Status: queued. v0.4.63 keeps the ASCII-banner fallback + `console --picture`
compositor wired; this note captures the design for the menu-theming work
that lands once iPXE rebuilt with `IMAGE_PNG` is published.
## How iVentoy actually does it
iVentoy is closed-source for its menu, but the supporting bits are
public at https://github.com/ventoy/PXE — a vanilla iPXE snapshot
(`iPXE/ipxe-bd13697`) used to produce the loader binaries iVentoy
serves over TFTP (`pxeboot.efi`, `iventoy_loader_16000`,
`iventoy_loader_16000_uefi`).
The graphical menu itself is rendered by iPXE's framebuffer console
with a baked-in PNG background via `console --picture` — same
primitive OpenPXE already uses in `crates/http-api/src/ipxe_script.rs`.
Evidence:
- The iPXE build in `ventoy/PXE` is configured with `CONSOLE_FRAMEBUFFER`
+ `IMAGE_PNG` + `CONSOLE_CMD` (the three flags `console --picture`
needs).
- iVentoy issue #11 confirms "iventoy using default 1024x768"; users
report 800x600 / 1024x768 / 1280x720 / 1280x1024 / 1920x1080 as
selectable resolutions from the iVentoy web UI **Configuration tab**,
not via EDID auto-detect. iPXE has no EDID parsing; the daemon writes
a resolution-tagged script per boot and serves the matching PNG.
- iVentoy docs explicitly state both Free and Pro editions **do not
support** modifying the boot background/title — it's baked into the
shipped PNG assets.
- Chrome is iPXE's native `menu` / `item` / `choose` widgets (single
highlight bar, no borders) painted on top of the PNG, with margins
set via `console --left/--right/--top/--bottom` to keep the text off
the logo. Not GRUB, not syslinux — UEFI iVentoy uses iPXE's
`snponly.efi` / `pxeboot.efi`, and `--picture` does work under UEFI
GOP despite older folklore.
Do not conflate this with Ventoy-USB, which is a separate codebase and
uses GRUB2 themes (`theme.txt`, `background_ventoy.png`, `select_c.png`).
## Rust ingredients to replicate / surpass
Most of these already exist in the workspace.
1. **Compositor (extend, don't replace)** — extend
`crates/iso-store/src/pxe_logo.rs` to emit per-resolution PNGs
(1024x768, 1280x1024, 1920x1080 as the v1 set). `image` +
`imageproc` crates handle scaling; `ab_glyph` / `fontdue` for raster
text (subtitle, hostname, version). One source SVG/logo, three to
five rendered PNGs cached on disk.
2. **Script generator**`ipxe_script.rs` already emits
`console --picture … || console`. Add a `?res=` query param (or
per-MAC client hint persisted in `hosts.json`) and serve the matching
PNG plus matching `console --x --y` line. Keep the text-console
fallback already in place.
3. **Resolution selection** — iPXE exposes `${vesa-x}` / `${vesa-y}` on
BIOS; UEFI side we can probe firmware vars at chain-time. The simpler
v1 is a "low-res / hi-res" toggle in Settings plus a per-host
override — mirrors iVentoy's UX, no kernel helper needed. True EDID
parsing is overkill for the first cut.
4. **Chrome upgrades over iVentoy** — iPXE menus are limited (single
highlight, no borders). To look distinctly cooler without leaving
iPXE: paint border / title / footer **into the PNG**, leave a window
in the middle, then `console --left/--right/--top/--bottom` to inset
the iPXE menu exactly into that window. ASCII box-drawing inside the
menu remains fragile (iPXE mangles non-ASCII on some builds — already
noted in `ipxe_script.rs`).
## Recommended architecture for the next OpenPXE release
- Build a `pxe_theme` module beside `pxe_logo.rs`: takes operator logo
+ theme tokens (accent colour, title, footer) and renders a layered
PNG (background gradient → framing chrome → logo → title bar → footer
with `${hostname}` / `${version}` / `${ip}`) at the three target
resolutions. Cache by hash of inputs.
- Serve at `/branding/pxe-menu-{w}x{h}.png`. Default 1024x768; expose a
Settings dropdown.
- In `ipxe_script.rs`, emit
`console --picture …/pxe-menu-1024x768.png --left 80 --right 80 --top 180 --bottom 60 || console`,
then the existing `menu` / `item` / `choose` block — text now lands
inside the framed window.
- Compile iPXE with `CONSOLE_FRAMEBUFFER`, `IMAGE_PNG`, `CONSOLE_CMD`,
`CONSOLE_VESAFB` (BIOS) and `CONSOLE_EFIFB` (UEFI). The v0.4.61 image
attempted this in-Docker via QEMU emulation and hit `cc1` segfaults.
The follow-up will use a Gitea Actions runner pinned to native
`linux/amd64` (an Unraid host already exists for this).
- Stretch goal: a second "theme pack" that ships a layered PNG with
subtle scanlines / grid — iPXE can't animate, but a well-designed
static composite beats iVentoy's plain centered logo handily.
## Source URLs
- https://github.com/ventoy/PXE
- https://github.com/ventoy/PXE/tree/master/iPXE
- https://github.com/ventoy/PXE/issues/11 — 1024x768 default
- https://github.com/ventoy/PXE/issues/59 — iVentoy iPXE EFI loader
- https://ipxe.org/cmd/console — `--picture` and compile flags
- https://github.com/ipxe/ipxe/discussions/945 — background image how-to
- https://github.com/ipxe/ipxe/discussions/802 — `CONSOLE_FRAMEBUFFER`
requirement
- https://github.com/ipxe/ipxe/discussions/1006 — picture resolution
behaviour
- https://www.iventoy.com/en/doc_edition.html — background / title not
user-customisable
- https://kingtam.win/archives/iventoy.html — third-party iPXE-based
iVentoy alternative
@@ -0,0 +1,199 @@
# OpenPXE v0.5.1 — SAML SSO wiring + Settings/Storage UI consolidation
**Date:** 2026-05-31
**Author:** Miles Ward (with Claude)
**Status:** Approved design → implementation
## Summary
Three workstreams for v0.5.1:
1. **Wire SAML 2.0 SSO** end-to-end (currently config is persisted but no runtime
sign-in exists). Pure-Rust implementation that preserves the static-musl /
no-OpenSSL architecture, mirroring how FleetDM exposes and handles SAML.
2. **Fold the Advanced sidebar tab into Settings** as a collapsible section.
3. **Merge the Storage tab's SMB and NFS cards** into one "Remote shares" card
with a protocol dropdown.
Then bump `0.5.0 → 0.5.1`, build the static musl image, push `:0.5.1` + `:latest`
to Gitea, create the release, and scrub registry credentials.
## Decisions (locked with the user)
- **Crypto:** pure-Rust via `bergshamra` (XML-DSig + exclusive c14n, RustCrypto-based,
`#![forbid(unsafe_code)]`, ~99% xmlsec interop). `samael` is rejected — it
hard-requires OpenSSL/`xmlsec`/`libxml2` C deps, which would break the static
musl binary and the project's pure-Rust / no-OpenSSL architecture.
- **Access model:** any SAML assertion the IdP successfully authenticates and that
we cryptographically verify mints a full operator session. No user table, no
roles, no domain allowlist. The local admin account remains a guaranteed
fallback owner regardless of SSO state.
- **Flows:** SP-initiated (the "Sign in with <IdP>" button) is always on.
IdP-initiated is supported but gated behind an `allow_idp_initiated` toggle
(default off), mirroring FleetDM's "Allow SSO login initiated by identity
provider."
## Scope boundaries (v0.5.1)
In scope: SP-initiated + (gated) IdP-initiated login, signature verification on the
SAML Response/Assertion, full SP-side semantic validation, SP metadata endpoint,
login-page button wiring.
Out of scope (note for later releases): EncryptedAssertion (assertions must be
unencrypted), signed AuthnRequests (sent unsigned; Keycloak "client signature
required" must be off), Single Logout (SLO), multi-user accounts / RBAC / JIT role
mapping.
---
## Workstream 1 — SAML SP wiring (pure-Rust)
### New dependencies (workspace)
- `bergshamra` — XML-DSig verification + exclusive c14n (pure Rust).
- `roxmltree` (read/navigate) and/or `quick-xml` (build/serialize) — parse IdP
metadata + SAMLResponse, build AuthnRequest and SP metadata.
- `x509-parser` — extract the IdP signing certificate / public key from metadata.
- `flate2` — raw DEFLATE for the HTTP-Redirect binding.
- `base64` — encode/decode SAMLRequest/SAMLResponse.
All pure-Rust → the `x86_64-unknown-linux-musl` static build stays OpenSSL-free.
Exact `bergshamra` function signatures (`verify`, `DsigContext`, `KeysManager`,
`Key`, `VerifiedReference`, `VerifyResult`) will be pinned against the installed
crate source during implementation.
### Module boundaries
Pure protocol logic lives in `openpxe-core` (no axum dependency, unit-testable);
HTTP wiring lives in `openpxe-http-api`.
- `crates/core/src/saml/mod.rs` — public surface + shared types
(`VerifiedPrincipal { email, display_name, name_id, session_index }`, `SamlError`).
- `crates/core/src/saml/metadata.rs` — parse IdP `EntityDescriptor`: IdP EntityID,
`SingleSignOnService` locations + bindings, and one or more X.509 signing
certificates. Also build **our** SP metadata XML.
- `crates/core/src/saml/authn_request.rs` — build an AuthnRequest, return both the
request ID (to track) and the encoded HTTP-Redirect query value
(deflate → base64 → URL-encode).
- `crates/core/src/saml/response.rs` — decode `SAMLResponse` (base64 → XML),
**verify the signature via bergshamra** against the IdP cert, then enforce SP
semantics, returning `VerifiedPrincipal` or a typed `SamlError`.
### SP-side validation (response.rs)
After a cryptographically valid signature over the Response and/or the Assertion:
1. `Status` is `Success`.
2. `Destination` (if present) equals our ACS URL.
3. `Conditions/AudienceRestriction/Audience` equals our SP EntityID.
4. `NotBefore` / `NotOnOrAfter` within bounds (allow small clock skew, e.g. ±60s).
5. `InResponseTo` matches an outstanding request we issued (SP-initiated). Absent
for IdP-initiated, which is only accepted when `allow_idp_initiated` is true.
6. Assertion-ID replay guard: reject a previously consumed assertion ID.
7. NameID is the email (`nameid-format:emailAddress`). Display name read from
common attributes (`name`, `displayname`, `cn`, `urn:oid:2.5.4.3`).
XML Signature Wrapping (XSW) defenses come from bergshamra (duplicate-ID rejection,
strict positional verification); enable its strict verification options. We
additionally confirm the verified `Reference` covers the element we read claims from.
### State (in `openpxe-http-api`)
Two small TTL-pruned in-memory stores (parking_lot `Mutex<HashMap<...>>`):
- **Outstanding requests:** `request_id → issued_at`, TTL ≈ 5 min, for `InResponseTo`.
- **Consumed assertions:** `assertion_id → expires_at`, TTL = assertion validity,
for replay protection.
(In-memory is acceptable: a single-container app; a restart simply invalidates
in-flight logins.)
### Routes (all pre-auth; added to the public allowlist in the auth middleware)
- `GET /api/sso/login` → build AuthnRequest, record its ID, 302 to the IdP SSO URL
(HTTP-Redirect binding) with `SAMLRequest` + `RelayState`.
- `POST /api/sso/acs` → consume `SAMLResponse` (form-encoded). Verify + validate.
On success: `SessionStore::create(email)`, set the `openpxe_session` cookie
(same attributes as forms login), 302 to the dashboard. On failure: 302 back to
the login page with an error indicator. (Mirrors FleetDM's `/sso/callback`.)
- `GET /api/sso/metadata` → serve our SP `EntityDescriptor` XML for IdP import.
### Config changes (`crates/core/src/sso.rs`)
Add to `SsoConfig` (preserve existing fields + validation):
- `entity_id: String` — SP Entity ID (mirrors FleetDM's "Entity ID"); defaults to
the configured public base URL. The ACS URL is derived as
`<public_base_url>/api/sso/acs`.
- `allow_idp_initiated: bool` — default `false`.
`GET /api/sso` returns the new fields; `PUT /api/sso` validates and persists them.
### Login page (`crates/webui/src/app.js`)
Replace the "configured · runtime pending" message: the existing
"Sign in with <IdP>" button navigates to `GET /api/sso/login`. Render the IdP logo
(if `idp_logo_url` set) and use `idp_name` as the label. Keep the existing
FleetDM-style login layout.
### Testing
- `core/saml` unit tests using a self-signed test keypair we control:
- Parse representative Keycloak IdP metadata → correct SSO URL + cert.
- Build an AuthnRequest → well-formed, deflate/base64 round-trips, ID recorded.
- A correctly signed Response → `VerifiedPrincipal { email, .. }`.
- Reject: tampered signature, expired (`NotOnOrAfter`), wrong audience,
replayed assertion ID, unsigned response, `Status != Success`.
- `http-api` integration test: `GET /api/sso/login` returns a 302 with a
`SAMLRequest` query param; a crafted signed `SAMLResponse` POSTed to
`/api/sso/acs` (signed with the test key) sets an `openpxe_session` cookie.
---
## Workstream 2 — Advanced tab → Settings
- Remove the `Advanced` sidebar entry (`crates/webui/src/index.html`) and its
`advanced` view route in `app.js`.
- In the Settings view, append a **collapsible "Advanced" disclosure**
(default-collapsed) at the bottom containing the existing **Webhook
Notifications** card and the **API reference** block (moved out of the removed
Advanced view).
- No backend changes; `/api/notify*` and `/api/docs` endpoints are unchanged.
---
## Workstream 3 — Storage: merge SMB + NFS → "Remote shares"
- Replace the separate "SMB shares" and "NFS shares" cards with a single
**"Remote shares"** card:
- One add-form with a **protocol dropdown (SMB / NFS)**. Selecting the protocol
swaps the fields: SMB → server, share, guest checkbox, username, password;
NFS → server, export path.
- One unified table with a leading **Protocol** column (SMB/NFS badge), then
server/share-or-export, auth, ISO count, reachability, and Re-scan / Remove
actions.
- **No backend changes.** The form dispatches to the existing
`POST /api/smb-shares` or `POST /api/nfs-shares`; the table merges
`GET /api/smb-shares` + `GET /api/nfs-shares`, tagging each row with its
protocol. Re-scan/Remove call the existing per-protocol endpoints.
- Leaves the card pattern open for a future "Config files" card.
---
## Release
1. Bump workspace version `0.5.0 → 0.5.1` (`Cargo.toml`).
2. `cargo fmt`, `cargo clippy`, `cargo test` (all crates) green.
3. Build the static musl binary + Docker image; verify SAML deps compile clean
under musl (no OpenSSL/C linkage).
4. Push `openpxe:0.5.1` + `openpxe:latest` to Gitea via the established
temp-DOCKER_CONFIG pipeline; scrub credentials (logout + verify no token traces).
5. Create the Gitea release `v0.5.1` with notes.
## Risks
- `bergshamra` is pre-1.0 and unaudited. Mitigation: pin the version, enable strict
verification, keep the local-admin fallback, and own the SP-semantic checks
carefully (audience/Conditions/replay/InResponseTo — where SP vulns usually live).
- SAML is security-sensitive; negative tests (tamper/expiry/audience/replay/unsigned)
are part of the definition of done, not optional.
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Build PNG-enabled iPXE binaries from source.
#
# Why from source: the official boot.ipxe.org binaries (and the
# Debian-packaged ones) are NOT built with CONSOLE_FRAMEBUFFER +
# IMAGE_PNG + CONSOLE_CMD, so `console --picture` is a no-op on them —
# you can't paint a graphical boot-menu background. iVentoy solves this
# by shipping its own iPXE build with exactly those three flags; we do
# the same, from upstream iPXE, with a thin auditable config delta
# (deploy/ipxe/local/{general,console}.h).
#
# Why a real cross-compiler instead of QEMU: building amd64 iPXE by
# emulating an amd64 gcc under QEMU on an arm64 host intermittently
# segfaults cc1 (the reason this was stuck for ~8 releases). Running a
# NATIVE arm64 gcc that cross-targets x86_64 (CROSS_COMPILE=
# x86_64-linux-gnu-) sidesteps emulation entirely — the compiler is a
# native binary, it just emits x86_64 objects. This stage is meant to
# run on $BUILDPLATFORM (the native builder arch), NOT the emulated
# target platform.
#
# Outputs (into $DEST), using the filenames OpenPXE's arch mapping
# expects:
# snponly.efi x86_64 UEFI, PNG-enabled
# ipxe.efi x86_64 UEFI, PNG-enabled (bundled drivers)
#
# We build ONLY x86_64 UEFI, always via the x86_64 cross toolchain
# (`x86_64-linux-gnu-gcc`). That's deliberately host-arch-agnostic: it
# works whether this stage runs on an arm64 Mac builder or an amd64 CI
# runner, because the cross compiler runs native and emits x86_64
# either way. Building arm64-efi or BIOS here would re-introduce a
# dependency on the host arch (native arm64 build) or a 32-bit multilib
# toolchain — so those arches keep their upstream-fetched (no-PNG)
# binaries and fall back to the menu's clean `|| console` text screen.
# Modern PXE clients are overwhelmingly x86_64 UEFI, which get the full
# graphical background.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DEST="${1:-$ROOT/assets/ipxe}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
# Pinned upstream iPXE. Rolling master is fine functionally, but a pin
# keeps builds reproducible and protects against a transient master
# breakage. Bump deliberately.
IPXE_REPO="https://github.com/ipxe/ipxe.git"
IPXE_REF="${IPXE_REF:-master}"
echo ">> cloning iPXE ($IPXE_REF)"
git clone --depth 1 --branch "$IPXE_REF" "$IPXE_REPO" "$WORK/ipxe" 2>/dev/null \
|| git clone "$IPXE_REPO" "$WORK/ipxe"
SRC="$WORK/ipxe/src"
echo ">> applying OpenPXE config overrides (PNG + framebuffer + console cmd)"
mkdir -p "$SRC/config/local"
cp "$ROOT/deploy/ipxe/local/general.h" "$SRC/config/local/general.h"
cp "$ROOT/deploy/ipxe/local/console.h" "$SRC/config/local/console.h"
mkdir -p "$DEST"
# x86_64 UEFI — cross-compiled with the native arm64 gcc targeting
# x86_64. HOST_CC stays the native cc for iPXE's build-time utilities
# (elf2efi, zbin, …); only the target objects use the cross compiler.
echo ">> building x86_64 UEFI (snponly.efi, ipxe.efi)"
make -C "$SRC" -j"$(nproc)" \
CROSS_COMPILE=x86_64-linux-gnu- \
bin-x86_64-efi/snponly.efi \
bin-x86_64-efi/ipxe.efi
cp "$SRC/bin-x86_64-efi/snponly.efi" "$DEST/snponly.efi"
cp "$SRC/bin-x86_64-efi/ipxe.efi" "$DEST/ipxe.efi"
echo ">> iPXE build complete:"
ls -l "$DEST"/snponly.efi "$DEST"/ipxe.efi