Compare commits

..
29 Commits
Author SHA1 Message Date
Miles WardandClaude Opus 4.8 93cd2a42a9 v0.5.0: fix update-check repository URL (inherit workspace repository)
The About-tab "check for updates" returned "repository URL not
configured at build time" because the http-api crate didn't inherit the
workspace `repository` field, leaving CARGO_PKG_REPOSITORY empty. Add
`repository.workspace = true` so the Gitea releases API URL derives
correctly, and strengthen the unit test to assert the URL is present.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 2. NFS AUTH_SYS credential — fixes NFS3ERR_ACCES

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

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

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

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

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

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

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

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

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

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

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

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

## 3. Disk-space card on the Dashboard

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

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

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

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

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

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

## What's new

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

## What's the same

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

## Why nfs3_client

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

## Tests

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

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

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

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

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

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

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

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

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

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

What's gone:

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

What's new:

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

Why subprocess and not a Rust crate:

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

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

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

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

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

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

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

What's new:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Backend

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

## HTTP API

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

## iPXE prompt

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

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

## UI

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

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

## Menu indicator

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

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

## Tests

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

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

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

## Operational notes

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

## Naming changes

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

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

## Brand assets

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

## CSS rename

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

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

## Validation

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

## Migration notes for operators on v0.2.0

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

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

## New features

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

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

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

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

## Code cleanup pass

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

## Windows workflow validation

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

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

## Docs

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

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

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

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

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

## Phase 1 — protocol stack

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

## Phase 2 — UX + Windows

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

## Phase 3 — MVP hardening

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

## Phase 4 — UI restructure + remote storage

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

## Tests

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

cargo build --workspace and cargo clippy --workspace --all-targets
both finish clean (warnings only, no errors).
2026-04-29 02:47:00 -04:00
33 changed files with 5631 additions and 1122 deletions
+16
View File
@@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"Bash(cargo check *)",
"Bash(cargo build *)",
"Bash(cargo clippy *)",
"Bash(cargo fmt *)",
"Bash(cargo tree *)",
"Bash(cargo doc *)",
"Bash(cargo test --workspace --lib)",
"Bash(cargo test --workspace)",
"Bash(cargo --version)",
"Bash(rustc --version)"
]
}
}
Generated
+511 -23
View File
@@ -248,12 +248,28 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cc"
version = "1.2.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "cipher"
version = "0.4.4"
@@ -369,8 +385,8 @@ dependencies = [
"dhcproto-macros",
"hex",
"ipnet",
"rand",
"thiserror",
"rand 0.8.6",
"thiserror 1.0.69",
"trust-dns-proto",
"url",
]
@@ -402,6 +418,22 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "email-encoding"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
dependencies = [
"base64",
"memchr",
]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -454,6 +486,12 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"
version = "1.1.9"
@@ -590,8 +628,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -602,7 +656,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"r-efi 6.0.0",
"wasip2",
"wasip3",
]
@@ -682,6 +736,17 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hostname"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
dependencies = [
"cfg-if",
"libc",
"windows-link",
]
[[package]]
name = "http"
version = "1.4.0"
@@ -752,6 +817,23 @@ dependencies = [
"pin-project-lite",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -760,13 +842,21 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64",
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"tokio",
"tower-service",
"tracing",
]
[[package]]
@@ -944,6 +1034,16 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "iri-string"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -962,6 +1062,8 @@ version = "0.3.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
dependencies = [
"cfg-if",
"futures-util",
"once_cell",
"wasm-bindgen",
]
@@ -978,6 +1080,34 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lettre"
version = "0.11.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349"
dependencies = [
"async-trait",
"base64",
"email-encoding",
"email_address",
"fastrand",
"futures-io",
"futures-util",
"hostname",
"httpdate",
"idna 1.1.0",
"mime",
"nom",
"percent-encoding",
"quoted_printable",
"rustls",
"socket2 0.6.3",
"tokio",
"tokio-rustls",
"url",
"webpki-roots",
]
[[package]]
name = "libc"
version = "0.2.185"
@@ -1011,6 +1141,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "matchers"
version = "0.2.0"
@@ -1102,6 +1238,46 @@ dependencies = [
"version_check",
]
[[package]]
name = "nfs3_client"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ac227029a6127f3474b4fb61cbf2d3dddbe9424ad50c24fada68b633687a532"
dependencies = [
"fastrand",
"nfs3_types",
"tokio",
]
[[package]]
name = "nfs3_macros"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06d8fb377e6efeb91911a8ed962a69615535e167e675025a2be8fa109751426a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "nfs3_types"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5d5ab1a9fdfeab2f03b36b6300dea4ea3806e6a057cb479628b41d65e356b94"
dependencies = [
"nfs3_macros",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -1140,7 +1316,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openpxe"
version = "0.4.62"
version = "0.5.0"
dependencies = [
"anyhow",
"axum",
@@ -1162,7 +1338,7 @@ dependencies = [
[[package]]
name = "openpxe-core"
version = "0.4.62"
version = "0.5.0"
dependencies = [
"anyhow",
"bcrypt",
@@ -1170,7 +1346,7 @@ dependencies = [
"serde",
"serde_json",
"tempfile",
"thiserror",
"thiserror 1.0.69",
"time",
"tokio",
"toml",
@@ -1181,21 +1357,21 @@ dependencies = [
[[package]]
name = "openpxe-dhcp-proxy"
version = "0.4.62"
version = "0.5.0"
dependencies = [
"anyhow",
"bytes",
"dhcproto",
"openpxe-core",
"socket2 0.5.10",
"thiserror",
"thiserror 1.0.69",
"tokio",
"tracing",
]
[[package]]
name = "openpxe-http-api"
version = "0.4.62"
version = "0.5.0"
dependencies = [
"anyhow",
"axum",
@@ -1203,6 +1379,7 @@ dependencies = [
"futures",
"hyper",
"image",
"lettre",
"mime",
"mime_guess",
"openpxe-core",
@@ -1210,10 +1387,11 @@ dependencies = [
"openpxe-iso-store",
"openpxe-webui",
"parking_lot",
"reqwest",
"serde",
"serde_json",
"tempfile",
"thiserror",
"thiserror 1.0.69",
"time",
"tokio",
"tokio-stream",
@@ -1226,31 +1404,34 @@ dependencies = [
[[package]]
name = "openpxe-ipxe-assets"
version = "0.4.62"
version = "0.5.0"
dependencies = [
"openpxe-core",
"rust-embed",
"thiserror",
"thiserror 1.0.69",
"tracing",
]
[[package]]
name = "openpxe-iso-store"
version = "0.4.62"
version = "0.5.0"
dependencies = [
"anyhow",
"bcrypt",
"bytes",
"futures",
"hex",
"image",
"libc",
"nfs3_client",
"nfs3_types",
"openpxe-core",
"parking_lot",
"serde",
"serde_json",
"sha2",
"tempfile",
"thiserror",
"thiserror 1.0.69",
"time",
"tokio",
"tokio-util",
@@ -1260,21 +1441,21 @@ dependencies = [
[[package]]
name = "openpxe-tftp"
version = "0.4.62"
version = "0.5.0"
dependencies = [
"anyhow",
"bytes",
"openpxe-core",
"openpxe-ipxe-assets",
"socket2 0.5.10",
"thiserror",
"thiserror 1.0.69",
"tokio",
"tracing",
]
[[package]]
name = "openpxe-webui"
version = "0.4.62"
version = "0.5.0"
[[package]]
name = "parking_lot"
@@ -1379,6 +1560,61 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.4",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.3",
"tracing",
"windows-sys 0.52.0",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -1388,6 +1624,18 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "quoted_printable"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
@@ -1401,8 +1649,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
@@ -1412,7 +1670,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
@@ -1424,6 +1692,15 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -1450,6 +1727,61 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rust-embed"
version = "8.11.0"
@@ -1485,6 +1817,12 @@ dependencies = [
"walkdir",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustix"
version = "1.1.4"
@@ -1498,6 +1836,42 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -1626,6 +2000,12 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@@ -1725,6 +2105,9 @@ name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]]
name = "synstructure"
@@ -1756,7 +2139,16 @@ version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl 2.0.18",
]
[[package]]
@@ -1770,6 +2162,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "thread_local"
version = "1.1.9"
@@ -1863,6 +2266,16 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.18"
@@ -1960,12 +2373,14 @@ dependencies = [
"http-body-util",
"http-range-header",
"httpdate",
"iri-string",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower-layer",
"tower-service",
"tracing",
@@ -2074,14 +2489,20 @@ dependencies = [
"idna 0.2.3",
"ipnet",
"lazy_static",
"rand",
"rand 0.8.6",
"smallvec",
"thiserror",
"thiserror 1.0.69",
"tinyvec",
"tracing",
"url",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.20.0"
@@ -2121,6 +2542,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -2179,6 +2606,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -2216,6 +2652,16 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.68"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.118"
@@ -2270,6 +2716,19 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
@@ -2282,6 +2741,35 @@ dependencies = [
"semver",
]
[[package]]
name = "web-sys"
version = "0.3.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
+15 -2
View File
@@ -12,7 +12,7 @@ members = [
]
[workspace.package]
version = "0.4.62"
version = "0.5.0"
edition = "2021"
rust-version = "1.95"
license = "MIT OR Apache-2.0"
@@ -35,7 +35,7 @@ axum = { version = "0.7", features = ["macros", "multipart", "http2"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] }
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"
@@ -58,6 +58,19 @@ once_cell = "1.19"
parking_lot = "0.12"
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"] }
openpxe-core = { path = "crates/core" }
openpxe-dhcp-proxy = { path = "crates/dhcp-proxy" }
openpxe-tftp = { path = "crates/tftp" }
+36
View File
@@ -44,6 +44,14 @@ struct Inner {
/// MIME of the active logo, mirroring `logo_filename`. Cached here
/// so the HTTP layer can set Content-Type without re-sniffing.
logo_mime: Option<String>,
/// Monotonic counter bumped on every set/clear. Surfaces as a
/// cache-bust token (`/assets/logo.svg?r=<rev>`) so the browser
/// fetches the new bytes the moment the operator swaps the 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,
}
/// In-memory + on-disk override registry. Cheap to clone; locks are
@@ -150,6 +158,7 @@ impl BrandingStore {
let mut g = self.inner.write();
g.logo_filename = Some(filename.clone());
g.logo_mime = Some(mime.to_string());
g.rev = g.rev.wrapping_add(1);
}
self.persist();
tracing::info!(
@@ -166,6 +175,7 @@ impl BrandingStore {
let mut g = self.inner.write();
let removed = g.logo_filename.take();
g.logo_mime = None;
g.rev = g.rev.wrapping_add(1);
removed
};
if let Some(name) = removed {
@@ -185,6 +195,14 @@ impl BrandingStore {
self.inner.read().logo_filename.is_some()
}
/// Cache-bust token for the logo asset URL. Changes on every
/// set/clear so `/assets/logo.svg?r=<rev>` resolves to a fresh URL
/// whenever the operator swaps the brand mark. Stable otherwise.
#[must_use]
pub fn logo_rev(&self) -> u64 {
self.inner.read().rev
}
fn persist(&self) {
let snap = self.inner.read().clone();
let body = match serde_json::to_vec_pretty(&snap) {
@@ -292,6 +310,23 @@ mod tests {
assert!(!entries.iter().any(|n| n == "logo.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("image/png", "png", b"\x89PNG\r\n\x1a\nfake").unwrap();
assert_eq!(b.logo_rev(), 1);
b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake2").unwrap();
assert_eq!(b.logo_rev(), 2);
b.clear_logo().unwrap();
assert_eq!(b.logo_rev(), 3);
// Survives a restart.
drop(b);
let b2 = BrandingStore::load_or_default(dir.path());
assert_eq!(b2.logo_rev(), 3);
}
#[test]
fn sanitize_ext_strips_separators_and_path_chars() {
assert_eq!(sanitize_ext("svg"), "svg");
@@ -319,6 +354,7 @@ mod tests {
let inner = Inner {
logo_filename: Some("logo.png".into()),
logo_mime: Some("image/png".into()),
rev: 0,
};
std::fs::write(
brand_dir.join("branding.json"),
+3
View File
@@ -12,9 +12,11 @@ pub mod error;
pub mod host_bindings;
pub mod log_bus;
pub mod metrics;
pub mod notify;
pub mod queue;
pub mod settings;
pub mod sso;
pub mod wol;
pub use arch::{ClientArch, FirmwareClass};
pub use auth::{AdminAccount, AdminPublic, AdminStore};
@@ -27,5 +29,6 @@ pub use error::{Error, Result};
pub use host_bindings::{normalize_mac, HostBinding, HostBindings};
pub use log_bus::{LogBus, LogBusLayer, LogLine};
pub use metrics::{HttpRoute, Metrics};
pub use notify::{NotifyConfig, NotifyKind, NotifyStore};
pub use queue::{DeploymentQueue, QueueEntry};
pub use settings::{Settings, SettingsStore, TimeoutAction};
+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(_))));
}
}
+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[..]);
}
}
+10
View File
@@ -4,6 +4,10 @@ version.workspace = true
edition.workspace = true
license.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"
[lints]
@@ -34,6 +38,12 @@ mime_guess.workspace = true
uuid.workspace = true
# v0.4.5 Forms auth: lock-free session store and cookie helpers.
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
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] }
+583 -100
View File
@@ -31,11 +31,11 @@ use axum::{
Json, Router,
};
use openpxe_core::{
ext_for_mime, BootEvent, ClientEvent, Error, Settings, SsoConfig, ALLOWED_LOGO_MIMES,
MAX_LOGO_BYTES,
ext_for_mime, wol, BootEvent, ClientEvent, Error, NotifyConfig, Settings, SsoConfig,
ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
};
use openpxe_ipxe_assets::asset_bytes;
use openpxe_iso_store::{IsoCategory, IsoMeta, NfsAddRequest};
use openpxe_iso_store::{IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest};
use serde::Deserialize;
use serde_json::json;
use std::net::SocketAddr;
@@ -132,10 +132,21 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/queue/poll/:entry_id", get(api_queue_poll))
.route("/api/queue/assign", post(api_queue_assign))
.route("/api/queue/:entry_id", delete(api_queue_release))
// Phase 4: NFS share manager.
.route("/api/nfs", get(api_nfs_list).post(api_nfs_add))
.route("/api/nfs/:id", delete(api_nfs_remove))
.route("/api/nfs/:id/scan", post(api_nfs_scan))
// v0.4.65: SMB share manager (userspace via smbclient). The
// kernel-mount NFS routes that v0.4.64 shipped are gone — they
// didn't work on hosts whose kernel lacked the nfs client
// modules (Unraid), and no container-side configuration could
// load a host kernel module. `smbclient` speaks SMB over a
// plain TCP socket in userspace, works in every container.
.route("/api/smb-shares", get(api_smb_shares_list).post(api_smb_shares_add))
.route("/api/smb-shares/:id", delete(api_smb_shares_remove))
.route("/api/smb-shares/:id/scan", post(api_smb_shares_scan))
// v0.4.67: NFSv3 share manager (pure-Rust in-process client).
// Ships alongside SMB. Routes are parallel so the UI can
// reuse the same form/error/hint rendering for both.
.route("/api/nfs-shares", get(api_nfs_shares_list).post(api_nfs_shares_add))
.route("/api/nfs-shares/:id", delete(api_nfs_shares_remove))
.route("/api/nfs-shares/:id/scan", post(api_nfs_shares_scan))
// Phase 4: Network info (read-only) + DNS edit.
.route("/api/network", get(api_network).put(api_network_put))
// Phase 4: live-log stream + recent buffer for the Terminal tab.
@@ -148,9 +159,19 @@ pub fn build_router(state: AppState) -> Router {
// pins a MAC to a boot entry; /boot.ipxe?mac=... chains directly.
.route("/api/hosts", get(api_hosts_list).post(api_hosts_upsert))
.route("/api/hosts/:mac", delete(api_hosts_remove))
// v0.5.0: Wake-on-LAN a bound host. Sends a magic packet to the
// limited broadcast + the server's own subnet broadcast.
.route("/api/hosts/:mac/wol", post(api_hosts_wol))
// Rolling "host log" of boot events: what image actually
// started installing on what MAC/IP, and when. Persisted to disk.
.route("/api/boot-log", get(api_boot_log))
// v0.5.0: notification config (Advanced tab) + a "send test"
// probe. GET redacts the SMTP password.
.route("/api/notify", get(api_notify_get).put(api_notify_put))
.route("/api/notify/test", post(api_notify_test))
// v0.5.0: About-tab update check — queries the Gitea releases
// API and compares against the running version.
.route("/api/updates/check", get(api_updates_check))
// Phase 5: Prometheus scrape endpoint. Plain text exposition
// format. No auth — the metrics surface is intentionally
// boring (counts, no payloads).
@@ -197,7 +218,15 @@ async fn index(State(state): State<AppState>) -> Response {
// browsers re-fetch JS/CSS after an upgrade. We use the OpenPXE
// binary version — every release ships a new value, every release
// forces a fresh URL on each asset.
let html = openpxe_webui::index_html(&state.public_base_url, env!("CARGO_PKG_VERSION"));
// logo_rev cache-busts the brand mark / favicon independently of
// the release version, so an operator who swaps the custom logo
// sees it update on the next reload without waiting for an upgrade.
let html = openpxe_webui::index_html(
&state.public_base_url,
env!("CARGO_PKG_VERSION"),
state.branding.logo_rev(),
state.branding.has_logo(),
);
(
[
(
@@ -300,74 +329,75 @@ async fn ui_logo(State(state): State<AppState>) -> Response {
.into_response()
}
/// v0.4.61: PXE menu logo composed for the iPXE `console --picture`
/// call. The operator can upload any raster image (PNG / JPEG / WebP /
/// GIF) of any aspect ratio; this handler decodes it, draws it
/// centered-top onto a fixed 1024×768 canvas, and returns PNG bytes.
/// That gives the same look as iVentoy regardless of what the operator
/// uploaded — a portrait logo, a wide wordmark, a square monogram all
/// land in the same place on the boot screen.
/// PXE boot-menu background, composed for the iPXE `console --picture`
/// call. Returns a full-screen 1024×768 PNG: a dark field with the
/// operator's uploaded logo across the top, or — when no logo is set —
/// a default OpenPXE mark on the same dark field. Either way the
/// endpoint *always* returns a valid PNG so the menu's `console
/// --picture` paints a real background instead of falling through to
/// bare text (v0.4.69 — replaces the old ASCII wordmark).
///
/// SVG uploads still 404 here — iPXE can't rasterize SVG, and rather
/// than haul in `resvg` we ask the operator to provide a raster when
/// they want a custom PXE-side logo. (The WebUI keeps using the SVG.)
/// Any raster the operator uploads (PNG / JPEG / WebP / GIF) is decoded
/// and transcoded to PNG here, since iPXE only consumes PNG. SVG
/// uploads can't be rasterized without hauling in `resvg`, so an SVG
/// brand mark falls back to the *default* background for the PXE screen
/// (the WebUI still renders the SVG natively in the top-left).
async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
let Some(path) = state.branding.logo_path() else {
return (StatusCode::NOT_FOUND, "no custom logo configured").into_response();
// Resolve the operator's raster upload, if any and if it's a format
// iPXE/our compositor can consume. SVG (or a missing/unreadable
// file) yields `None`, which composes the default background.
let raster: Option<Vec<u8>> = match (state.branding.logo_path(), state.branding.logo_mime()) {
(Some(path), Some(mime)) if mime != "image/svg+xml" => {
tokio::fs::read(&path).await.ok()
}
_ => None,
};
let Some(mime) = state.branding.logo_mime() else {
return (StatusCode::NOT_FOUND, "no mime recorded").into_response();
};
if mime == "image/svg+xml" {
return (
StatusCode::NOT_FOUND,
"operator-uploaded logo is SVG; PXE menu requires a raster (PNG / JPEG / WebP / GIF)",
)
.into_response();
}
let bytes = match tokio::fs::read(&path).await {
Ok(b) => b,
let composed = match tokio::task::spawn_blocking(move || {
openpxe_iso_store::pxe_logo::compose_pxe_background(raster.as_deref())
})
.await
{
Ok(Ok(png)) => png,
Ok(Err(e)) => {
// A decode failure on the operator's upload shouldn't blank
// the boot screen — fall back to the default background.
tracing::warn!(
target: "openpxe::http::branding",
error = %e, "PXE background compose failed on upload; using default"
);
match tokio::task::spawn_blocking(|| {
openpxe_iso_store::pxe_logo::compose_pxe_background(None)
})
.await
{
Ok(Ok(png)) => png,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
"failed to compose PXE background",
)
.into_response();
}
}
}
Err(e) => {
return (
StatusCode::NOT_FOUND,
format!("custom logo unreadable: {e}"),
StatusCode::INTERNAL_SERVER_ERROR,
format!("pxe-background task failed: {e}"),
)
.into_response();
}
};
// Compose to a fixed 1024×768 PNG so the PXE menu paints the logo
// centered-top regardless of the operator's source dimensions. The
// `image` crate is pure-Rust + sync; offload to a blocking task
// because Lanczos resampling on a 4K source can take tens of
// milliseconds and we don't want to block the executor.
let composed =
match tokio::task::spawn_blocking(move || openpxe_iso_store::pxe_logo::compose_pxe_logo(&bytes))
.await
{
Ok(Ok(png)) => png,
Ok(Err(e)) => {
tracing::warn!(
target: "openpxe::http::branding",
error = %e, "failed to compose PXE logo PNG"
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to compose PXE logo: {e}"),
)
.into_response();
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("pxe-logo task failed: {e}"),
)
.into_response();
}
};
(
[
(header::CONTENT_TYPE, HeaderValue::from_static("image/png")),
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
// No-cache so a freshly uploaded logo paints on the next
// boot without a stale composite lingering.
(
header::CACHE_CONTROL,
HeaderValue::from_static("no-cache, max-age=0"),
),
],
composed,
)
@@ -593,11 +623,22 @@ async fn boot_sub(
.filter(|m| !m.is_empty());
state.boot_log.record(&BootEvent {
timestamp: time::OffsetDateTime::now_utc(),
mac: mac_normalized,
mac: mac_normalized.clone(),
ip: peer_ip,
target_id: entry.id.clone(),
target_title: format!("{} — {}", iso.filename, entry.title),
});
// v0.5.0: fire-and-forget notification on the
// canonical "a machine is imaging" moment.
let who = mac_normalized
.clone()
.or_else(|| peer_ip.map(|ip| ip.to_string()))
.unwrap_or_else(|| "an unknown client".into());
spawn_notify(
&state,
"PXE boot started",
&format!("{who} started booting {} ({}).", iso.filename, entry.title),
);
return text_plain(render_entry(entry, &settings, base));
}
}
@@ -638,12 +679,111 @@ async fn iso_raw(
headers: HeaderMap,
) -> Response {
let id = filename.strip_suffix(".iso").unwrap_or(&filename);
let Some(path) = state.iso_store.iso_path_for(id) else {
// v0.4.65: SMB-sourced ISOs have no on-disk path — they're
// streamed live from the remote share via `smbclient`. We look
// up the meta first to decide whether to take the path-based
// local route or the subprocess-based SMB route.
let Some(meta) = state.iso_store.get(id) else {
return (StatusCode::NOT_FOUND, "no such iso").into_response();
};
match stream_file_range(&path, headers.get(header::RANGE)).await {
Ok(r) => r,
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
match &meta.source {
IsoSource::Local => {
let Some(path) = state.iso_store.iso_path_for(id) else {
return (StatusCode::NOT_FOUND, "no such iso").into_response();
};
match stream_file_range(&path, headers.get(header::RANGE)).await {
Ok(r) => r,
Err(e) => {
(StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response()
}
}
}
IsoSource::Smb {
share_id,
relative_path,
} => {
// Range requests aren't supported for SMB sources in
// v0.4.65 — smbclient's CLI can't seek mid-stream. iPXE
// chain loading and ISO sanboot do whole-file sequential
// reads, so this works in practice. A 416 here lets the
// client fall back to a full GET if it tried a range.
if headers.get(header::RANGE).is_some() {
return Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{}", meta.size_bytes))
.body(Body::empty())
.unwrap();
}
match state.smb_shares.stream_iso(share_id, relative_path).await {
Ok(stream) => {
let reader = stream.stdout;
let body_stream = tokio_util::io::ReaderStream::new(reader);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(header::CONTENT_LENGTH, meta.size_bytes)
// Tell intermediaries we don't support
// ranges on this resource; saves them from
// even trying.
.header(header::ACCEPT_RANGES, "none")
.body(Body::from_stream(body_stream))
.unwrap()
}
Err(e) => (StatusCode::BAD_GATEWAY, format!("smb stream: {e}")).into_response(),
}
}
IsoSource::Nfs {
share_id,
relative_path,
} => {
// v0.4.67: NFS sources support Range requests because
// NFSv3 READ3 takes an explicit offset. We resolve the
// requested byte range here and pass start/len down to
// the streamer which seeks into the file via READ3.
let total = meta.size_bytes;
let range = match parse_range(headers.get(header::RANGE), total) {
Some(triple) => triple,
None if headers.get(header::RANGE).is_some() => {
return Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{total}"))
.body(Body::empty())
.unwrap();
}
// No Range header — serve the whole file.
None => (0, total.saturating_sub(1), false),
};
let (start, end, partial) = range;
let len = if total == 0 { 0 } else { end - start + 1 };
let max_len = if total == 0 { None } else { Some(len) };
match state
.nfs_shares
.stream_iso(share_id, relative_path, start, max_len)
.await
{
Ok(stream) => {
let body = Body::from_stream(stream);
let status = if partial {
StatusCode::PARTIAL_CONTENT
} else {
StatusCode::OK
};
let mut builder = Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_LENGTH, len);
if partial {
builder = builder.header(
header::CONTENT_RANGE,
format!("bytes {start}-{end}/{total}"),
);
}
builder.body(body).unwrap()
}
Err(e) => (StatusCode::BAD_GATEWAY, format!("nfs stream: {e}")).into_response(),
}
}
}
}
@@ -651,6 +791,10 @@ async fn iso_file(
State(state): State<AppState>,
AxumPath((id, path)): AxumPath<(String, String)>,
) -> Response {
// In-ISO file extraction is only supported for local ISOs — it
// needs random-access reads into the ISO9660 directory tree, which
// smbclient's whole-file streaming can't do efficiently. SMB-
// sourced ISOs use the raw streaming endpoint above instead.
let Some(iso_path) = state.iso_store.iso_path_for(&id) else {
return (StatusCode::NOT_FOUND, "no such iso").into_response();
};
@@ -1026,17 +1170,30 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "Abort a chunked upload session and remove the .partial file."},
],
},
{
"name": "SMB shares",
"endpoints": [
{"method": "GET", "path": "/api/smb-shares",
"summary": "List configured SMB shares with connection state and iso counts."},
{"method": "POST", "path": "/api/smb-shares",
"summary": "Register an SMB share. Body: { server, share, guest, username?, password?, port? }."},
{"method": "DELETE", "path": "/api/smb-shares/:id",
"summary": "Forget a share and drop its entries from the ISO store."},
{"method": "POST", "path": "/api/smb-shares/:id/scan",
"summary": "Re-list a share for new ISOs."},
],
},
{
"name": "NFS shares",
"endpoints": [
{"method": "GET", "path": "/api/nfs",
"summary": "List configured NFS shares with mount state and iso counts."},
{"method": "POST", "path": "/api/nfs",
"summary": "Mount an NFS share. Body: { server, export, version, read_only }."},
{"method": "DELETE", "path": "/api/nfs/:id",
"summary": "Unmount a share and drop its entries from the ISO store."},
{"method": "POST", "path": "/api/nfs/:id/scan",
"summary": "Re-walk a mounted share for ISOs."},
{"method": "GET", "path": "/api/nfs-shares",
"summary": "List configured NFSv3 shares with connection state and iso counts."},
{"method": "POST", "path": "/api/nfs-shares",
"summary": "Register an NFSv3 share. Body: { server, export, port? }. Auth is AUTH_SYS only; access control is by client IP on the server side."},
{"method": "DELETE", "path": "/api/nfs-shares/:id",
"summary": "Forget a share and drop its entries from the ISO store."},
{"method": "POST", "path": "/api/nfs-shares/:id/scan",
"summary": "Re-list a share for new ISOs."},
],
},
{
@@ -1111,10 +1268,25 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "Pin a MAC to a boot target. Body: { mac, target, label }."},
{"method": "DELETE", "path": "/api/hosts/:mac",
"summary": "Remove a binding."},
{"method": "POST", "path": "/api/hosts/:mac/wol",
"summary": "Send a Wake-on-LAN magic packet to a bound MAC (limited + subnet broadcast)."},
{"method": "GET", "path": "/api/boot-log",
"summary": "Ring of recent boot events (timestamp, mac, ip, target)."},
],
},
{
"name": "Notifications & updates",
"endpoints": [
{"method": "GET", "path": "/api/notify",
"summary": "Current notification config (SMTP password redacted)."},
{"method": "PUT", "path": "/api/notify",
"summary": "Replace notification config. Body: { enabled, kind, webhook_url, smtp_* }."},
{"method": "POST", "path": "/api/notify/test",
"summary": "Send a test notification using the saved config."},
{"method": "GET", "path": "/api/updates/check",
"summary": "Compare the running version against the latest Gitea release."},
],
},
{
"name": "Operator console",
"endpoints": [
@@ -1453,8 +1625,13 @@ async fn api_list_clients(State(state): State<AppState>) -> Json<serde_json::Val
async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
let smb = state.smb.as_ref().map(|s| s.snapshot());
let nfs = state.nfs.list();
let nfs_active = nfs.iter().filter(|m| m.mounted).count();
// v0.4.65+v0.4.67: external storage shares — SMB (userspace
// smbclient) and NFS (in-process nfs3_client). Dashboard tile
// sums both so operators see a single "reachable shares" number.
let smb_shares = state.smb_shares.list();
let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count();
let nfs_shares = state.nfs_shares.list();
let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count();
let isos = state.iso_store.list();
let clients = state.clients.list();
let queue_entries = state.queue.list();
@@ -1473,7 +1650,9 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
state
.metrics
.set_queue_counts(queue_entries.len() as u64, imaging as u64);
state.metrics.set_nfs_active(nfs_active as u64);
state
.metrics
.set_nfs_active((smb_reachable + nfs_reachable) as u64);
state.metrics.record_http(openpxe_core::HttpRoute::Api);
let now = time::OffsetDateTime::now_utc();
let uptime_secs = (now - state.started_at).whole_seconds().max(0);
@@ -1488,8 +1667,13 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
"ipxe_assets": openpxe_ipxe_assets::list_assets(),
"settings": state.settings.snapshot(),
"smb": smb,
"nfs_count": nfs.len(),
"nfs_active": nfs_active,
"smb_share_count": smb_shares.len(),
"smb_share_reachable": smb_reachable,
// v0.4.67: NFSv3 share counts. The dashboard tile sums these
// with the SMB counts above ("N shares reachable") so the
// top-line metric works regardless of protocol mix.
"nfs_share_count": nfs_shares.len(),
"nfs_share_reachable": nfs_reachable,
"host_bindings": state.hosts.len(),
"custom_logo": state.branding.has_logo(),
"uptime_secs": uptime_secs,
@@ -1704,32 +1888,83 @@ async fn api_queue_release(
}
}
// ─── NFS share API ─────────────────────────────────────────────────────────
// ─── SMB share API (v0.4.65) ───────────────────────────────────────────────
//
// Replaces the NFS share manager from v0.4.64. The wire shape is similar
// — a {shares: [...]} list, a POST that returns either the share or a
// structured {error, stderr, hint} body — so the UI can render both the
// same way.
async fn api_nfs_list(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!({ "mounts": state.nfs.list() }))
async fn api_smb_shares_list(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!({ "shares": state.smb_shares.list() }))
}
async fn api_nfs_add(State(state): State<AppState>, Json(req): Json<NfsAddRequest>) -> Response {
match state.nfs.add(req).await {
Ok(m) => (StatusCode::CREATED, Json(m)).into_response(),
// Anything from the manager surfaces as a user-fixable validation
// error — bad host, kernel without NFS support, missing
// `mount.nfs`, dead server. We pass the message through verbatim
// so the UI can show it to the operator.
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
async fn api_smb_shares_add(
State(state): State<AppState>,
Json(req): Json<SmbAddRequest>,
) -> Response {
match state.smb_shares.add(req).await {
Ok(s) => (StatusCode::CREATED, Json(s)).into_response(),
Err(err) => (StatusCode::BAD_REQUEST, Json(err)).into_response(),
}
}
async fn api_nfs_remove(State(state): State<AppState>, AxumPath(id): AxumPath<String>) -> Response {
match state.nfs.remove(&id).await {
async fn api_smb_shares_remove(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Response {
match state.smb_shares.remove(&id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
}
async fn api_nfs_scan(State(state): State<AppState>, AxumPath(id): AxumPath<String>) -> Response {
match state.nfs.rescan(&id).await {
async fn api_smb_shares_scan(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Response {
match state.smb_shares.rescan(&id).await {
Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(),
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
}
}
// ─── NFS share API (v0.4.67) ───────────────────────────────────────────────
//
// Parallel to the SMB shares API. The pure-Rust NFSv3 client
// (`nfs3_client`) gives us in-process listing and streaming, no
// subprocess. Unlike SMB, NFS-sourced ISOs support HTTP Range
// requests — NFSv3 READ3 takes an explicit offset.
async fn api_nfs_shares_list(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!({ "shares": state.nfs_shares.list() }))
}
async fn api_nfs_shares_add(
State(state): State<AppState>,
Json(req): Json<NfsAddRequest>,
) -> Response {
match state.nfs_shares.add(req).await {
Ok(s) => (StatusCode::CREATED, Json(s)).into_response(),
Err(err) => (StatusCode::BAD_REQUEST, Json(err)).into_response(),
}
}
async fn api_nfs_shares_remove(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Response {
match state.nfs_shares.remove(&id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
}
async fn api_nfs_shares_scan(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Response {
match state.nfs_shares.rescan(&id).await {
Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(),
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
}
@@ -1823,6 +2058,220 @@ async fn api_hosts_remove(
}
}
/// v0.5.0: Wake-on-LAN a bound host. Sends a magic packet to the limited
/// broadcast (255.255.255.255) and the server's own subnet broadcast
/// (computed from the advertised IP + detected mask), which covers the
/// common "same VLAN as OpenPXE" case with zero network config. We only
/// wake MACs that are actually bound — keeps this from being an open
/// "spray packets at any MAC" endpoint.
async fn api_hosts_wol(
State(state): State<AppState>,
AxumPath(mac): AxumPath<String>,
) -> Response {
if state.hosts.lookup(&mac).is_none() {
return (
StatusCode::NOT_FOUND,
"no host binding for that MAC — bind it first",
)
.into_response();
}
// Compute the server's subnet broadcast from the advertised IP +
// detected mask so the packet reaches the right VLAN even if the
// limited broadcast is filtered. Best-effort: skip if either won't
// parse.
let server_ip = state
.public_base_url
.strip_prefix("http://")
.unwrap_or(&state.public_base_url)
.split(':')
.next()
.unwrap_or("")
.parse::<std::net::Ipv4Addr>();
let mask = state.subnet_mask.parse::<std::net::Ipv4Addr>();
let mut broadcasts = Vec::new();
if let (Ok(ip), Ok(m)) = (server_ip, mask) {
broadcasts.push(wol::subnet_broadcast(ip, m));
}
// The send is a blocking std UDP call; push it off the async
// executor.
let mac_owned = mac.clone();
let result =
tokio::task::spawn_blocking(move || wol::wake(&mac_owned, &broadcasts)).await;
match result {
Ok(Ok(n)) => {
// Fire-and-forget notification — nice "someone woke a box"
// signal, never blocks the response.
spawn_notify(
&state,
"Wake-on-LAN sent",
&format!("OpenPXE sent a Wake-on-LAN magic packet to {mac}."),
);
Json(json!({ "ok": true, "broadcasts": n })).into_response()
}
Ok(Err(e)) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("wol task failed: {e}")).into_response(),
}
}
// ─── Notifications (v0.5.0) ───────────────────────────────────────────────
async fn api_notify_get(State(state): State<AppState>) -> Json<NotifyConfig> {
// Redact the SMTP password before it leaves the process.
Json(state.notify.snapshot().redacted())
}
async fn api_notify_put(
State(state): State<AppState>,
Json(cfg): Json<NotifyConfig>,
) -> Response {
match state.notify.replace(cfg) {
Ok(saved) => (StatusCode::OK, Json(saved.redacted())).into_response(),
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
}
}
/// Send a test notification using the *currently saved* config (not the
/// request body) so the operator validates exactly what's persisted.
async fn api_notify_test(State(state): State<AppState>) -> Response {
let cfg = state.notify.snapshot();
match crate::notify::send(
&cfg,
"OpenPXE test notification",
"If you're reading this, OpenPXE notifications are wired up correctly. \
This is a test from the Advanced settings tab.",
)
.await
{
Ok(()) => Json(json!({ "ok": true })).into_response(),
Err(e) => (StatusCode::BAD_GATEWAY, e).into_response(),
}
}
// ─── Update check (v0.5.0) ────────────────────────────────────────────────
/// Query the project's Gitea releases API for the latest published tag
/// and compare it to the running version. This is the only outbound
/// call OpenPXE makes that isn't operator-initiated data movement, and
/// it's strictly on-demand (the About tab's "Check for updates" button)
/// — never a background poll, keeping the air-gapped promise intact.
async fn api_updates_check() -> Response {
let current = env!("CARGO_PKG_VERSION");
let Some(api) = gitea_releases_api_url() else {
return Json(json!({
"current": current,
"error": "repository URL not configured at build time",
}))
.into_response();
};
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(8))
.user_agent(concat!("OpenPXE/", env!("CARGO_PKG_VERSION")))
.build()
{
Ok(c) => c,
Err(e) => {
return Json(json!({ "current": current, "error": format!("client: {e}") }))
.into_response();
}
};
match client.get(&api).send().await {
Ok(resp) if resp.status().is_success() => {
let body: serde_json::Value = resp.json().await.unwrap_or(json!({}));
let latest_tag = body
.get("tag_name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let html_url = body
.get("html_url")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let update_available = version_is_newer(
latest_tag.trim_start_matches('v'),
current,
);
Json(json!({
"current": current,
"latest": latest_tag,
"update_available": update_available,
"html_url": html_url,
}))
.into_response()
}
Ok(resp) => Json(json!({
"current": current,
"error": format!("releases API returned HTTP {}", resp.status()),
}))
.into_response(),
Err(e) => Json(json!({
"current": current,
"error": format!("could not reach the releases API: {e}"),
}))
.into_response(),
}
}
/// Derive the Gitea `releases/latest` API URL from the compile-time
/// repository URL (`https://host/owner/repo`).
fn gitea_releases_api_url() -> Option<String> {
let repo = option_env!("CARGO_PKG_REPOSITORY").unwrap_or("");
let rest = repo
.strip_prefix("https://")
.or_else(|| repo.strip_prefix("http://"))?;
let mut parts = rest.trim_end_matches('/').splitn(3, '/');
let host = parts.next()?;
let owner = parts.next()?;
let name = parts.next()?;
if host.is_empty() || owner.is_empty() || name.is_empty() {
return None;
}
Some(format!(
"https://{host}/api/v1/repos/{owner}/{name}/releases/latest"
))
}
/// Compare two dotted numeric versions; `true` when `latest` is strictly
/// newer than `current`. Non-numeric / malformed parts compare as 0, so
/// a garbage tag never falsely reports an update.
fn version_is_newer(latest: &str, current: &str) -> bool {
fn parts(v: &str) -> Vec<u64> {
v.split('.')
.map(|p| p.chars().take_while(char::is_ascii_digit).collect::<String>())
.map(|s| s.parse::<u64>().unwrap_or(0))
.collect()
}
let (l, c) = (parts(latest), parts(current));
for i in 0..l.len().max(c.len()) {
let lv = l.get(i).copied().unwrap_or(0);
let cv = c.get(i).copied().unwrap_or(0);
if lv != cv {
return lv > cv;
}
}
false
}
/// Fire a notification on a detached task. Never blocks the caller and
/// never surfaces an error — boot/WoL paths must not hinge on a webhook.
fn spawn_notify(state: &AppState, subject: &str, body: &str) {
let cfg = state.notify.snapshot();
if !cfg.is_usable() {
return;
}
let subject = subject.to_string();
let body = body.to_string();
tokio::spawn(async move {
if let Err(e) = crate::notify::send(&cfg, &subject, &body).await {
tracing::warn!(target: "openpxe::notify", "notification send failed: {e}");
}
});
}
// ─── Boot event log ───────────────────────────────────────────────────────
async fn api_boot_log(State(state): State<AppState>) -> Json<serde_json::Value> {
@@ -1849,9 +2298,21 @@ async fn api_metrics(State(state): State<AppState>) -> Response {
state
.metrics
.set_queue_counts(queue_entries.len() as u64, imaging as u64);
state
.metrics
.set_nfs_active(state.nfs.list().iter().filter(|m| m.mounted).count() as u64);
// v0.4.67: gauge tracks reachable external-storage shares
// across both protocols (SMB userspace + NFSv3 in-process).
let smb_ok = state
.smb_shares
.list()
.iter()
.filter(|m| m.reachable)
.count();
let nfs_ok = state
.nfs_shares
.list()
.iter()
.filter(|m| m.reachable)
.count();
state.metrics.set_nfs_active((smb_ok + nfs_ok) as u64);
let now = time::OffsetDateTime::now_utc();
let uptime = (now - state.started_at).whole_seconds().max(0) as u64;
@@ -1870,6 +2331,28 @@ async fn api_metrics(State(state): State<AppState>) -> Response {
mod tests {
use super::*;
#[test]
fn version_newer_detects_updates() {
assert!(version_is_newer("0.5.1", "0.5.0"));
assert!(version_is_newer("0.6.0", "0.5.9"));
assert!(version_is_newer("1.0.0", "0.9.9"));
assert!(!version_is_newer("0.5.0", "0.5.0"));
assert!(!version_is_newer("0.4.69", "0.5.0"));
// A garbage / empty tag must never falsely report an update.
assert!(!version_is_newer("", "0.5.0"));
assert!(!version_is_newer("not-a-version", "0.5.0"));
}
#[test]
fn gitea_api_url_derives_from_repo() {
// The crate inherits the workspace `repository`, so
// CARGO_PKG_REPOSITORY is populated and the update check has a
// real URL to hit (regressed once when the inherit was missing).
let u = gitea_releases_api_url().expect("repository must be configured at build time");
assert!(u.contains("/api/v1/repos/"), "got: {u}");
assert!(u.ends_with("/releases/latest"), "got: {u}");
}
#[test]
fn range_full() {
let (s, e, p) = parse_range(None, 1000).unwrap();
+12
View File
@@ -303,12 +303,20 @@ pub async fn api_logout(
/// * `authenticated: false` — admin exists, no session; show login.
/// * `authenticated: true` + `user` — let the dashboard load.
pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
// v0.5.0: include branding bootstrap so the pre-auth login/setup
// screens can render the FleetDM-style full-width custom logo (and
// cache-bust it) without an extra round trip. `/api/me` is public,
// and the logo asset is public, so this leaks nothing sensitive.
let has_custom_logo = state.branding.has_logo();
let logo_rev = state.branding.logo_rev();
if !state.admin.is_configured() {
return (
StatusCode::OK,
Json(json!({
"setup_required": true,
"authenticated": false,
"has_custom_logo": has_custom_logo,
"logo_rev": logo_rev,
})),
)
.into_response();
@@ -323,6 +331,8 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
"authenticated": true,
"user": state.admin.snapshot(),
"session_user": u,
"has_custom_logo": has_custom_logo,
"logo_rev": logo_rev,
})),
)
.into_response(),
@@ -331,6 +341,8 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
Json(json!({
"setup_required": false,
"authenticated": false,
"has_custom_logo": has_custom_logo,
"logo_rev": logo_rev,
})),
)
.into_response(),
+36 -38
View File
@@ -30,14 +30,15 @@ use std::fmt::Write as _;
/// Top-level OpenPXE boot menu. Serialized identically for BIOS and UEFI
/// clients because iPXE normalises the menu primitives across firmwares.
///
/// v0.4.6: rendered with an iVentoy-style polished frame — centered
/// OpenPXE wordmark banner at the top (ASCII so every iPXE build can
/// paint it), a footer carrying version + arch + firmware kind, and an
/// optional `console --picture` directive that paints the operator's
/// uploaded raster logo on top when the iPXE binary on the wire was
/// built with PNG support. The ASCII banner is always rendered so
/// even when the picture call no-ops the screen still reads as
/// "OpenPXE — here is the menu" rather than a featureless box.
/// v0.4.69: rendered with an iVentoy-style graphical frame — a
/// `console --picture` directive paints a full-screen PNG background
/// (the operator's uploaded logo on a dark field, or the default
/// OpenPXE mark) with the menu text overlaid below a reserved top
/// margin, plus a footer carrying version + arch + firmware kind. On
/// iPXE binaries built with `IMAGE_PNG` + `CONSOLE_FRAMEBUFFER` (our
/// x86_64 UEFI binaries, compiled from source) the background paints;
/// on binaries without PNG support the `|| console` fallback yields a
/// clean text menu. The old ASCII wordmark has been removed.
#[must_use]
pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String {
let mut s = String::new();
@@ -56,14 +57,20 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
let _ = writeln!(s, "set base-url {base}");
let _ = writeln!(s, "set esc:hex 1b");
let _ = writeln!(s, "set cls ${{esc:string}}[2J");
// v0.4.6: best-effort graphics console with the operator-uploaded
// raster logo. Falls back to plain text console on iPXE builds
// without PNG support — the `||` chain keeps a parse-clean
// single-statement form so even the strictest iPXE parsers accept
// it. The `console` reset at the end re-syncs the menu output.
// v0.4.69: graphical background. `/branding/pxe-logo` always
// returns a full-screen 1024×768 PNG now — the operator's logo on a
// dark field, or a default OpenPXE mark when none is uploaded. The
// `--top 290` reserves the top band (where the logo paints) so the
// menu text lands below it. On an iPXE build *with* `IMAGE_PNG` +
// `CONSOLE_FRAMEBUFFER` (our x86_64 UEFI binaries, built from source
// — see deploy/docker/Dockerfile) this paints the background and
// overlays the menu. On a build *without* PNG support (the fetched
// BIOS/i386/arm64 binaries) the whole `console --picture …` command
// fails and the `|| console` resets to a clean full-screen text
// menu. Either way there's no ASCII placeholder anymore.
let _ = writeln!(
s,
"console --picture {base}/branding/pxe-logo || console"
"console --picture {base}/branding/pxe-logo --top 290 || console"
);
// Map iPXE's ${{buildarch}} + ${{platform}} into the human form the
// user asked for (e.g. "x86 BIOS", "x86_64 UEFI", "arm64 UEFI").
@@ -77,19 +84,8 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
);
let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu OpenPXE - network boot menu");
// ASCII OpenPXE wordmark. Works on every iPXE build, including
// the boot.ipxe.org pre-builds we ship (which omit `IMAGE_PNG`,
// 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 -- |_|");
// v0.4.69: the ASCII wordmark is gone — the graphical background
// (set via `console --picture` above) carries the branding now.
let _ = writeln!(s, "item --gap");
let _ = writeln!(
s,
@@ -631,27 +627,29 @@ mod password_tests {
#[test]
fn top_menu_has_polished_branding_and_arch_footer() {
// v0.4.6 polish + v0.4.62 stability fixes: the menu emits a
// `console --picture` line that PNG-capable iPXE builds will
// honour (queued for a follow-up release once we can rebuild
// iPXE from source on native x86_64 hardware), an ASCII
// OpenPXE wordmark that works on every iPXE build (including
// the boot.ipxe.org pre-builds we currently ship), and a
// single-line footer carrying the OpenPXE version + arch.
// v0.4.69: the menu emits a `console --picture` line that paints
// a full-screen PNG background (the operator's logo, or the
// default OpenPXE mark) reserving a top margin for it, then
// falls back to a clean text console on iPXE builds without PNG
// support. The ASCII wordmark is gone — the graphical
// background carries the branding now. A single-line footer
// still carries the OpenPXE version + arch.
let settings = Settings::default();
let s = render_menu(&[], &settings, "http://10.0.0.5");
assert!(
s.contains("console --picture http://10.0.0.5/branding/pxe-logo"),
"missing console --picture line:\n{s}"
);
// The picture call reserves a top margin for the logo band.
assert!(s.contains("--top 290"), "missing --top margin:\n{s}");
// Picture-or-text-console must be a single statement so older
// iPXE parsers don't choke on the chain.
assert!(s.contains("|| console"), "missing graceful fallback:\n{s}");
// ASCII wordmark — paints on every iPXE build regardless of
// PNG support.
// The ASCII wordmark must be GONE — its removal is the whole
// point of v0.4.69's graphical background.
assert!(
s.contains("___ ___ __ __ ___"),
"ASCII banner missing first row:\n{s}"
!s.contains("___ ___ __ __ ___"),
"ASCII banner should have been removed:\n{s}"
);
// Footer with version + arch interpolation. The version comes
// from CARGO_PKG_VERSION at compile time.
+1
View File
@@ -18,6 +18,7 @@ pub mod auth;
pub mod ipxe_script;
pub mod iso_fs;
pub mod log_stream;
pub mod notify;
pub mod state;
pub mod terminal;
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}"))
}
+20 -7
View File
@@ -2,9 +2,9 @@ use crate::uploads::UploadSessions;
use crate::auth::SessionStore;
use openpxe_core::{
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
Metrics, SettingsStore, SsoStore,
Metrics, NotifyStore, SettingsStore, SsoStore,
};
use openpxe_iso_store::{IsoStore, NfsManager, SmbManager};
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager};
use std::sync::Arc;
use time::OffsetDateTime;
@@ -37,6 +37,10 @@ pub struct AppState {
/// SAML SSO configuration. v0.4.5 stores it; the actual SSO login
/// flow ships in a later release.
pub sso: SsoStore,
/// 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
/// text format. Cheap to clone (handles to atomics).
pub metrics: Metrics,
@@ -44,11 +48,20 @@ pub struct AppState {
/// `smb_dir` at startup; `None` in pure-Linux-only deployments where
/// Windows support is not wired in. Settings toggle drives start/stop.
pub smb: Option<Arc<SmbManager>>,
/// NFS share manager. Always present (mounting is opt-in by the
/// operator from the Storage tab); `add()` requires `mount.nfs` to be
/// available in the runtime image. Surfaces errors per-mount rather
/// than failing the global state.
pub nfs: NfsManager,
/// v0.4.65: SMB share manager — userspace consumer of remote SMB
/// shares via Samba's `smbclient` CLI. Replaces the kernel-mount
/// NFS path that v0.4.64 shipped; that path didn't work on hosts
/// (Unraid, etc.) whose kernel ships without the nfs/cifs client
/// 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,
/// Browser chunked upload state. Multipart uploads still go straight
/// through `IsoStore`, but the UI uses sessions so large ISO transfers
/// can show deterministic progress and leave visible partial files.
+186 -56
View File
@@ -88,8 +88,14 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
"isos" | "images" => Ok(isos_text(state)),
"clients" => Ok(clients_text(state)),
"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,
"nfs" => nfs_share_command(state, tail).await,
"log" => log_command(state, tail),
"whoami" => Ok("operator".to_string()),
"echo" => Ok(tail.join(" ")),
@@ -107,18 +113,22 @@ fn status_text(s: &AppState) -> String {
let clients = s.clients.list();
let queue_entries = s.queue.list();
let smb = s.smb.as_ref().map(|m| m.snapshot());
let nfs = s.nfs.list();
let nfs_active = nfs.iter().filter(|m| m.mounted).count();
let smb_shares = s.smb_shares.list();
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();
format!(
"OpenPXE {ver}\n\
base url: {base}\n\
interface: {nic}\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})\n\
clients: {n_clients}\n\
queue: {n_entries}\n\
smb: {smb}\n\
nfs mounts: {n_total} configured ({n_active} active)\n",
smb server: {smb}\n\
smb shares: {n_smb_total} configured ({n_smb_active} reachable)\n\
nfs shares: {n_nfs_total} configured ({n_nfs_active} reachable)\n",
ver = env!("CARGO_PKG_VERSION"),
base = s.public_base_url,
nic = if s.nic_name.is_empty() {
@@ -132,15 +142,21 @@ fn status_text(s: &AppState) -> String {
.iter()
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Local))
.count(),
n_smb = isos
.iter()
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Smb { .. }))
.count(),
n_nfs = isos
.iter()
.filter(|i| !matches!(i.source, openpxe_iso_store::IsoSource::Local))
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Nfs { .. }))
.count(),
n_clients = clients.len(),
n_entries = queue_entries.len(),
smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")),
n_total = nfs.len(),
n_active = nfs_active,
n_smb_total = smb_shares.len(),
n_smb_active = smb_reachable,
n_nfs_total = nfs_shares.len(),
n_nfs_active = nfs_reachable,
)
}
@@ -158,7 +174,9 @@ fn isos_text(s: &AppState) -> String {
for i in isos {
let src = match i.source {
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}"),
};
let _ = writeln!(
out,
@@ -264,31 +282,136 @@ 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) {
None | Some("list") => {
let mounts = s.nfs.list();
if mounts.is_empty() {
return Ok("(no NFS mounts configured)".into());
let shares = s.smb_shares.list();
if shares.is_empty() {
return Ok("(no SMB shares configured)".into());
}
let mut out = String::new();
let _ = writeln!(
out,
"{:<24} {:<6} {:<7} {:<6} TARGET",
"ID", "VER", "STATUS", "ISOS"
"{:<24} {:<7} {:<6} {:<6} TARGET",
"ID", "STATUS", "AUTH", "ISOS"
);
for m in mounts {
let status = if m.mounted { "ok" } else { "down" };
for m in shares {
let status = if m.reachable { "ok" } else { "down" };
let auth = if m.guest { "guest" } else { "user" };
let _ = writeln!(
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),
match m.version {
openpxe_iso_store::NfsVersion::V3 => "v3",
openpxe_iso_store::NfsVersion::V41 => "v4.1",
},
status,
m.iso_count,
m.server,
@@ -297,56 +420,58 @@ async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
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("mount") => {
// nfs mount <server>:<export> [v3|v41] [ro|rw]
Some("add") => {
// nfs add <server>:<export> [port]
let target = args
.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
.split_once(':')
.ok_or_else(|| "target must be 'server:/export'".to_string())?;
let version = match args.get(2).map(String::as_str) {
Some("v3") => openpxe_iso_store::NfsVersion::V3,
Some("v41") | None => openpxe_iso_store::NfsVersion::V41,
Some(other) => {
return Err(format!("unknown nfs version: {other} (expect v3 or v41)"))
}
};
let read_only = !matches!(args.get(3).map(String::as_str), Some("rw"));
let port = args.get(2).and_then(|s| s.parse::<u16>().ok());
let req = openpxe_iso_store::NfsAddRequest {
server: server.to_string(),
export: export.to_string(),
version,
read_only,
port,
};
match s.nfs.add(req).await {
Ok(m) => Ok(format!("mounted {} ({} isos)", m.id, m.iso_count)),
Err(e) => Err(format!("mount failed: {e}")),
match s.nfs_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("unmount") => {
Some("remove") => {
let id = args
.get(1)
.ok_or_else(|| "usage: nfs unmount <id>".to_string())?;
match s.nfs.remove(id).await {
Ok(()) => Ok(format!("unmounted {id}")),
Err(e) => Err(format!("unmount failed: {e}")),
.ok_or_else(|| "usage: nfs remove <id>".to_string())?;
match s.nfs_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: 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")),
Err(e) => Err(format!("scan failed: {e}")),
}
}
Some(other) => Err(format!(
"unknown nfs subcommand: {other}\ntry: nfs [list|mount|unmount|scan]"
"unknown nfs subcommand: {other}\ntry: nfs [list|add|remove|scan]"
)),
}
}
@@ -487,13 +612,18 @@ OpenPXE terminal — available commands:
queue assign-all <target> assign every waiting client
queue release <entry_id> release one queued client
nfs list list NFS mounts
nfs mount <s>:<e> [v3|v41] [ro|rw] add and mount an NFS share
nfs unmount <id> unmount and forget a share
nfs scan <id> re-scan a share for new ISOs
share list list configured SMB shares
share add //srv/share [auth] add an SMB share; auth = 'guest' or 'user:pass'
share remove <id> forget an SMB share
share scan <id> re-list a share for new ISOs
smb status SMB (Samba) state
smb start | stop | reload control smbd
nfs list list configured NFSv3 shares
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
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 tail [n] show the last n buffered lines (default 20)
@@ -508,10 +638,10 @@ mod tests {
#[test]
fn shell_split_basic() {
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!(
shell_split("nfs mount 10.0.0.5:/srv v41 ro"),
vec!["nfs", "mount", "10.0.0.5:/srv", "v41", "ro"]
shell_split("share add //nas/isos guest"),
vec!["share", "add", "//nas/isos", "guest"]
);
}
+203 -27
View File
@@ -14,7 +14,7 @@ use axum::body::Body;
use axum::http::{header, Request, StatusCode};
use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore};
use openpxe_http_api::{build_router, AppState};
use openpxe_iso_store::{IsoStore, NfsManager};
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbShareManager};
use tempfile::tempdir;
use tower::ServiceExt;
@@ -94,14 +94,15 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
let clients = ClientRegistry::new();
let queue = DeploymentQueue::new();
let settings = SettingsStore::load_or_default(dir.path());
let nfs = NfsManager::new(dir.path(), iso_store.clone());
iso_store.set_nfs_root(nfs.mount_root());
let smb_shares = SmbShareManager::new(dir.path(), iso_store.clone());
let nfs_shares = NfsShareManager::new(dir.path(), iso_store.clone());
let log_bus = LogBus::new(64);
let hosts = HostBindings::load_or_default(dir.path());
let boot_log = openpxe_core::BootLog::load_or_default(dir.path());
let branding = openpxe_core::BrandingStore::load_or_default(dir.path());
let admin = openpxe_core::AdminStore::load_or_default(dir.path());
let sso = openpxe_core::SsoStore::load_or_default(dir.path());
let notify = openpxe_core::NotifyStore::load_or_default(dir.path());
let sessions = openpxe_http_api::auth::SessionStore::default();
let metrics = Metrics::new();
let state = AppState {
@@ -115,9 +116,11 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
admin,
sessions,
sso,
notify,
metrics,
smb: None,
nfs,
smb_shares,
nfs_shares,
uploads: openpxe_http_api::uploads::UploadSessions::default(),
log_bus,
started_at: time::OffsetDateTime::now_utc(),
@@ -464,35 +467,168 @@ async fn no_external_urls_in_generated_ipxe() {
// ── Phase 4 integration tests ────────────────────────────────────────────
// v0.4.65: kernel-mount NFS replaced with userspace SMB via smbclient.
#[tokio::test]
async fn nfs_add_with_bad_export_is_rejected() {
// Validation must happen before we shell out to /bin/mount —
// otherwise the operator sees opaque kernel errors instead of a
// clear "your export must start with /" hint.
async fn smb_share_add_with_missing_server_is_rejected() {
// Validation must run before we shell out to smbclient — otherwise
// operators see opaque NT_STATUS codes for what's really a typo.
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, b) = post_json(
&app,
"/api/nfs",
r#"{"server":"10.0.0.5","export":"isos","version":"v41","read_only":true}"#,
"/api/smb-shares",
r#"{"server":"","share":"isos","guest":true}"#,
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let msg = String::from_utf8_lossy(&b);
assert!(
msg.contains("export"),
msg.to_lowercase().contains("server"),
"expected validation hint, got: {msg}"
);
}
#[tokio::test]
async fn nfs_list_starts_empty() {
async fn smb_share_add_requires_username_when_not_guest() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, b) = get(&app, "/api/nfs").await;
let (s, b) = post_json(
&app,
"/api/smb-shares",
r#"{"server":"10.0.0.5","share":"isos","guest":false}"#,
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let msg = String::from_utf8_lossy(&b);
assert!(
msg.to_lowercase().contains("username"),
"expected username hint, got: {msg}"
);
}
#[tokio::test]
async fn smb_share_add_rejects_paths_in_share_name() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, b) = post_json(
&app,
"/api/smb-shares",
r#"{"server":"10.0.0.5","share":"isos/subdir","guest":true}"#,
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let msg = String::from_utf8_lossy(&b);
assert!(
msg.to_lowercase().contains("share name"),
"expected share name hint, got: {msg}"
);
}
#[tokio::test]
async fn smb_shares_list_starts_empty() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, b) = get(&app, "/api/smb-shares").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["mounts"].as_array().unwrap().len(), 0);
assert_eq!(v["shares"].as_array().unwrap().len(), 0);
}
// v0.4.67: NFSv3 share manager (parallel to SMB).
// ── v0.5.0: notifications + Wake-on-LAN ────────────────────────────────────
#[tokio::test]
async fn notify_config_round_trips_and_redacts_smtp_password() {
let (state, _dir) = build_state().await;
let app = build_router(state);
// Save an SMTP config with a password.
let (s, _b) = put_json(
&app,
"/api/notify",
r#"{"enabled":true,"kind":"smtp","smtp_host":"smtp.example.com","smtp_port":587,"smtp_to":"[email protected]","smtp_from":"[email protected]","smtp_password":"s3cret"}"#,
)
.await;
assert_eq!(s, StatusCode::OK);
// GET must redact the password (never echo the real secret).
let (s, b) = get(&app, "/api/notify").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["enabled"], true);
assert_eq!(v["kind"], "smtp");
let pw = v["smtp_password"].as_str().unwrap_or("");
assert_ne!(pw, "s3cret", "raw password must never be returned");
assert!(!pw.is_empty(), "a set password should surface as a sentinel");
}
#[tokio::test]
async fn notify_enable_webhook_without_url_is_rejected() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, _b) = put_json(
&app,
"/api/notify",
r#"{"enabled":true,"kind":"slack","webhook_url":""}"#,
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn wol_on_unbound_mac_is_404() {
// WoL only fires for bound MACs — an arbitrary MAC must 404 so the
// endpoint isn't an open packet sprayer.
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, _b) = post_json(&app, "/api/hosts/aa:bb:cc:dd:ee:ff/wol", "{}").await;
assert_eq!(s, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn nfs_shares_list_starts_empty() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, b) = get(&app, "/api/nfs-shares").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["shares"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn nfs_shares_add_rejects_missing_server() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, b) = post_json(
&app,
"/api/nfs-shares",
r#"{"server":"","export":"/srv/isos"}"#,
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let msg = String::from_utf8_lossy(&b);
assert!(
msg.to_lowercase().contains("server"),
"expected server hint, got: {msg}"
);
}
#[tokio::test]
async fn nfs_shares_add_rejects_export_without_leading_slash() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, b) = post_json(
&app,
"/api/nfs-shares",
r#"{"server":"10.0.0.5","export":"srv/isos"}"#,
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let msg = String::from_utf8_lossy(&b);
assert!(
msg.to_lowercase().contains("export"),
"expected export-path hint, got: {msg}"
);
}
#[tokio::test]
@@ -1746,13 +1882,39 @@ async fn docs_lists_new_v0_4_5_endpoints() {
// ─── v0.4.6: PXE logo endpoint ────────────────────────────────────────────
#[tokio::test]
async fn pxe_logo_404_when_no_custom_logo_configured() {
async fn pxe_background_serves_default_when_no_custom_logo() {
// v0.4.69: the endpoint always returns a full-screen PNG background
// now — when no custom logo is configured it composes the default
// OpenPXE mark on a dark field rather than 404ing. This is what lets
// the PXE menu's `console --picture` paint a real background instead
// of falling back to the (now-removed) ASCII placeholder.
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, body) = get(&app, "/branding/pxe-logo").await;
assert_eq!(s, StatusCode::NOT_FOUND);
let text = std::str::from_utf8(&body).unwrap();
assert!(text.contains("no custom logo"), "got: {text}");
let res = app
.clone()
.oneshot(
Request::builder()
.uri("/branding/pxe-logo")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers()
.get(axum::http::header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap(),
"image/png"
);
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
assert!(body.starts_with(b"\x89PNG"), "default background not a PNG");
let width = u32::from_be_bytes([body[16], body[17], body[18], body[19]]);
assert_eq!(width, 1024, "default background should be 1024 wide");
}
/// Build a tiny valid PNG via the `image` crate. The v0.4.61 PXE-logo
@@ -1771,10 +1933,11 @@ fn tiny_png() -> Vec<u8> {
}
#[tokio::test]
async fn pxe_logo_404_when_uploaded_logo_is_svg() {
// iPXE can't rasterize SVG, so an SVG upload deliberately doesn't
// light up the PXE menu's `console --picture` overlay — the menu
// simply paints without a logo.
async fn pxe_background_falls_back_to_default_for_svg_upload() {
// iPXE can't rasterize SVG, so an SVG upload doesn't paint as the
// PXE background — but v0.4.69 still returns the *default* OpenPXE
// background PNG (not a 404) so the boot screen stays graphical.
// The WebUI top-left continues to render the SVG natively.
let (state, _dir) = build_state().await;
state
.branding
@@ -1785,10 +1948,23 @@ async fn pxe_logo_404_when_uploaded_logo_is_svg() {
)
.unwrap();
let app = build_router(state);
let (s, body) = get(&app, "/branding/pxe-logo").await;
assert_eq!(s, StatusCode::NOT_FOUND);
let text = std::str::from_utf8(&body).unwrap();
assert!(text.contains("SVG"), "got: {text}");
let res = app
.clone()
.oneshot(
Request::builder()
.uri("/branding/pxe-logo")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
assert!(body.starts_with(b"\x89PNG"), "should serve default PNG for SVG");
let width = u32::from_be_bytes([body[16], body[17], body[18], body[19]]);
assert_eq!(width, 1024);
}
#[tokio::test]
+8
View File
@@ -35,5 +35,13 @@ libc = "0.2"
# encoder. Keeps the build slim — no JPEG2000, TIFF, BMP, etc.
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
# v0.4.67: pure-Rust NFSv3 client for reading remote ISOs without a
# kernel mount. See crates/iso-store/src/nfs_share.rs for usage.
nfs3_client = { workspace = true }
nfs3_types = { workspace = true }
# 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]
tempfile = "3.12"
+13 -2
View File
@@ -18,16 +18,27 @@
pub mod entry;
pub mod introspect;
pub mod nfs;
pub mod nfs_share;
pub mod pxe_logo;
pub mod smb;
pub mod smb_share;
pub mod store;
pub mod windows;
pub use entry::{BootEntry, BootKind, KernelArgs};
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_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};
pub use store::{
generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore,
UploadHandle,
-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
//! whatever raster the operator drops onto Settings → Branding. A wide
//! wordmark, a portrait stack, a square monogram — all three should
//! land in roughly the same place on the boot screen.
//! The brief (v0.4.69): match iVentoy's polished graphical PXE screen.
//! iPXE built with `CONSOLE_FRAMEBUFFER` + `IMAGE_PNG` paints a PNG to
//! the framebuffer via `console --picture`, then draws the text menu on
//! top (the console's default background colour is rendered transparent
//! so the picture shows through the menu's blank cells). So what we
//! produce here is a **full-screen 1024×768 background**, not just a
//! floating logo:
//!
//! Approach: decode the operator's upload, fit it into a fixed
//! 1024×768 canvas with the logo horizontally centered and pinned a
//! short margin from the top, re-encode as PNG, return the bytes. iPXE
//! built with `IMAGE_PNG` paints the result via `console --picture`.
//! - a solid dark field (matches the WebUI dark theme so the product
//! feels consistent from browser to bare metal), with
//! - the operator's uploaded logo composited across the top, leaving
//! the lower ~two-thirds clear for the iPXE menu text.
//!
//! The 1024×768 size matches the default VESA framebuffer iPXE picks
//! on most BIOS/UEFI consoles. Operators uploading 4K logos get
//! correctly downscaled; tiny icons get drawn at their native size,
//! centered, with transparent margins.
//! When no custom logo is uploaded we still return a designed
//! background — a dark field with a centered "rainbow-horizon" disc
//! echoing the bundled OpenPXE mark — so the boot screen is graphical
//! out of the box. This replaces the old ASCII wordmark entirely.
//!
//! We deliberately don't ship `resvg` for SVG support — keeping the
//! dependency surface narrow matters more than supporting SVG-only
//! brand assets. The WebUI's logo stays SVG-native (the browser
//! rasterizes it); the PXE menu wants a raster regardless.
//! iPXE does **not** scale pictures (confirmed against the decoder
//! source): the image is painted at native pixel size and the firmware
//! picks the smallest video mode that fits. 1024×768 is the universal
//! safe mode, so we pin the canvas there. Operators uploading a 4K logo
//! get it downscaled to fit the top band; tiny icons paint at native
//! size, centered.
//!
//! Input formats: anything the `image` crate decodes with our enabled
//! features — PNG, JPEG, WebP, GIF. iPXE itself only consumes PNG, so
//! we always *emit* PNG regardless of what the operator uploaded; a
//! WebP logo is transcoded here transparently.
use image::imageops::FilterType;
use image::{DynamicImage, ImageError, ImageFormat, Rgba, RgbaImage};
use std::io::Cursor;
/// Canvas dimensions used for the composed PXE logo. Picked to match
/// the framebuffer dimensions iPXE picks on most BIOS/UEFI consoles —
/// gives a 1:1 paint with no scaling at the firmware layer.
/// Canvas dimensions. Pinned to 1024×768 — the universal framebuffer
/// mode every BIOS/UEFI console supports, and iPXE doesn't scale.
pub const CANVAS_W: u32 = 1024;
pub const CANVAS_H: u32 = 768;
/// Maximum dimensions for the operator's logo inside the canvas. Any
/// upload larger than this in either axis is downscaled (preserving
/// aspect ratio) to fit. Smaller uploads paint at native size.
const LOGO_MAX_W: u32 = 600;
/// Bounding box for the operator's logo across the top band. Wider than
/// the old floating-logo box because the logo now anchors a full
/// background rather than sitting alone on transparency.
const LOGO_MAX_W: u32 = 760;
const LOGO_MAX_H: u32 = 200;
/// Top margin in pixels from the canvas's top edge to the logo's top
/// edge. Matches the visual rhythm of iVentoy's screen (logo at top,
/// menu below).
const LOGO_TOP_MARGIN: u32 = 64;
/// Top margin from the canvas top to the logo's top edge.
const LOGO_TOP_MARGIN: u32 = 72;
/// Compose `src_bytes` (any PNG/JPEG/WebP/GIF) into a centered-top
/// 1024×768 PNG and return the encoded bytes.
/// Background fill — a near-black with a faint blue cast, matching the
/// WebUI's dark theme surface so the product reads as one piece from
/// browser to PXE screen.
const BG: Rgba<u8> = Rgba([11, 14, 22, 255]);
/// Compose the operator's uploaded raster (`Some`) — or the default
/// OpenPXE mark (`None`) — into a full-screen 1024×768 PNG background
/// and return the encoded bytes.
///
/// Errors when the source can't be decoded or the encoded buffer can't
/// be written (only really fires on out-of-memory; the encoder itself
/// is infallible for well-formed inputs).
pub fn compose_pxe_logo(src_bytes: &[u8]) -> Result<Vec<u8>, ImageError> {
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();
/// Errors only when a provided `src_bytes` can't be decoded; the
/// `None` path and the PNG encode are infallible for our fixed canvas.
pub fn compose_pxe_background(src_bytes: Option<&[u8]>) -> Result<Vec<u8>, ImageError> {
let mut canvas: RgbaImage = RgbaImage::from_pixel(CANVAS_W, CANVAS_H, BG);
// Transparent canvas. iPXE 1.21+ honours alpha-channel transparency
// on framebuffer consoles; older builds simply draw the alpha as
// black, which still gives a sensible look.
let mut canvas: RgbaImage = RgbaImage::from_pixel(CANVAS_W, CANVAS_H, Rgba([0, 0, 0, 0]));
let logo_w = logo_rgba.width();
let logo_h = logo_rgba.height();
// Horizontal center, top-margin from the top. Saturating math
// means a logo wider than CANVAS_W (shouldn't happen after the
// downscale above, but defensive) just sits flush-left.
let off_x = CANVAS_W.saturating_sub(logo_w) / 2;
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());
match src_bytes {
Some(bytes) => {
let logo = image::load_from_memory(bytes)?;
let logo = downscale_to_fit(logo, LOGO_MAX_W, LOGO_MAX_H);
let logo_rgba = logo.to_rgba8();
let off_x = CANVAS_W.saturating_sub(logo_rgba.width()) / 2;
let off_y = LOGO_TOP_MARGIN.min(CANVAS_H.saturating_sub(logo_rgba.height()));
// `overlay` alpha-composites, so a transparent-background
// logo blends onto the dark field exactly as designed.
image::imageops::overlay(&mut canvas, &logo_rgba, off_x.into(), off_y.into());
}
None => draw_default_mark(&mut canvas),
}
let mut out = Vec::with_capacity(64 * 1024);
let mut out = Vec::with_capacity(128 * 1024);
DynamicImage::ImageRgba8(canvas).write_to(&mut Cursor::new(&mut out), ImageFormat::Png)?;
Ok(out)
}
/// Back-compat shim for the old name — callers that pass a raw logo and
/// want it composited get the same result as `compose_pxe_background`
/// with `Some`.
pub fn compose_pxe_logo(src_bytes: &[u8]) -> Result<Vec<u8>, ImageError> {
compose_pxe_background(Some(src_bytes))
}
/// Paint a centered "rainbow-horizon" disc onto the dark canvas as the
/// default brand mark when no operator logo is set. Pure pixel math —
/// no font, no SVG rasterizer, no extra deps. A filled circle with a
/// left-to-right hue sweep echoes the bundled `logo.svg` motif.
// Casts here are all bounded small-range geometry (radius ≤ 90, canvas
// ≤ 1024) — precision loss / wrap is structurally impossible.
#[allow(clippy::cast_precision_loss, clippy::cast_possible_wrap)]
fn draw_default_mark(canvas: &mut RgbaImage) {
let radius: i32 = 90;
let cx = (CANVAS_W / 2) as i32;
let cy = (LOGO_TOP_MARGIN + 100) as i32;
// Four-stop horizontal sweep across the disc (teal → blue → violet
// → magenta) — the OpenPXE palette.
let stops = [
[0x22u8, 0xd3, 0xaa],
[0x3b, 0x82, 0xf6],
[0x8b, 0x5c, 0xf6],
[0xec, 0x48, 0x99],
];
let r2 = radius * radius;
for dy in -radius..=radius {
for dx in -radius..=radius {
if dx * dx + dy * dy > r2 {
continue;
}
// Position across the disc in [0,1] left→right.
let t = (f32::from(i16::try_from(dx + radius).unwrap_or(0)))
/ (f32::from(i16::try_from(2 * radius).unwrap_or(1)));
let color = gradient_at(&stops, t);
// Soft edge: fade alpha in the outer 3px ring.
let dist = ((dx * dx + dy * dy) as f32).sqrt();
let alpha = if dist > (radius as f32 - 3.0) {
let edge = (radius as f32 - dist).clamp(0.0, 3.0) / 3.0;
(edge * 255.0) as u8
} else {
255
};
let px = cx + dx;
let py = cy + dy;
if px >= 0 && py >= 0 && (px as u32) < CANVAS_W && (py as u32) < CANVAS_H {
blend_pixel(canvas, px as u32, py as u32, color, alpha);
}
}
}
}
/// Linear interpolate across an N-stop palette at position `t` in [0,1].
// `segments`/`idx` are ≤ palette length (4) — f32 cast is exact.
#[allow(clippy::cast_precision_loss)]
fn gradient_at(stops: &[[u8; 3]], t: f32) -> [u8; 3] {
let t = t.clamp(0.0, 1.0);
let segments = stops.len() - 1;
let scaled = t * segments as f32;
let idx = (scaled.floor() as usize).min(segments - 1);
let frac = scaled - idx as f32;
let a = stops[idx];
let b = stops[idx + 1];
[
lerp(a[0], b[0], frac),
lerp(a[1], b[1], frac),
lerp(a[2], b[2], frac),
]
}
fn lerp(a: u8, b: u8, t: f32) -> u8 {
(f32::from(a) + (f32::from(b) - f32::from(a)) * t).round() as u8
}
/// Alpha-blend `color` at `alpha` over the existing canvas pixel.
fn blend_pixel(canvas: &mut RgbaImage, x: u32, y: u32, color: [u8; 3], alpha: u8) {
let bg = canvas.get_pixel(x, y).0;
let a = f32::from(alpha) / 255.0;
let out = Rgba([
lerp(bg[0], color[0], a),
lerp(bg[1], color[1], a),
lerp(bg[2], color[2], a),
255,
]);
canvas.put_pixel(x, y, out);
}
fn downscale_to_fit(img: DynamicImage, max_w: u32, max_h: u32) -> DynamicImage {
let (w, h) = (img.width(), img.height());
if w <= max_w && h <= max_h {
return img;
}
// 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)
}
@@ -99,54 +192,73 @@ mod tests {
}
#[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 out = compose_pxe_logo(&src).unwrap();
// Round-trip the output and confirm dimensions.
let img = image::load_from_memory(&out).unwrap();
let out = compose_pxe_background(Some(&src)).unwrap();
let img = image::load_from_memory(&out).unwrap().to_rgba8();
assert_eq!(img.width(), CANVAS_W);
assert_eq!(img.height(), CANVAS_H);
// A far corner should be the opaque dark background fill, not
// transparent — this is a full background now, not a floating
// logo on transparency.
let corner = img.get_pixel(CANVAS_W - 1, CANVAS_H - 1);
assert_eq!(corner.0, BG.0, "corner should be the dark fill");
}
#[test]
fn small_logo_centered_at_top_margin() {
fn custom_logo_painted_in_top_band() {
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();
// 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 cy = LOGO_TOP_MARGIN;
let inside = canvas.get_pixel(cx + 10, cy + 10);
assert_eq!(inside.0[3], 255, "logo pixel should be opaque");
assert!(inside.0[0] < 100 && inside.0[1] > 100 && inside.0[2] < 100, "color mismatch: {inside:?}");
let corner = canvas.get_pixel(CANVAS_W - 1, CANVAS_H - 1);
assert_eq!(corner.0[3], 0, "canvas corner should be transparent");
assert!(
inside.0[1] > 100 && inside.0[0] < 100,
"logo pixel color mismatch: {inside:?}"
);
}
#[test]
fn oversize_logo_is_downscaled_to_bounding_box() {
// 4000×800 image — bigger than LOGO_MAX_W and LOGO_MAX_H in
// 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();
fn default_background_is_dark_with_a_painted_mark() {
let out = compose_pxe_background(None).unwrap();
let canvas = image::load_from_memory(&out).unwrap().to_rgba8();
// Span row at the top margin should have non-transparent
// pixels somewhere; rows past the LOGO_TOP_MARGIN + LOGO_MAX_H
// should be entirely transparent.
let bottom_band_y = LOGO_TOP_MARGIN + LOGO_MAX_H + 10;
for x in 0..CANVAS_W {
let p = canvas.get_pixel(x, bottom_band_y);
assert_eq!(p.0[3], 0, "row {bottom_band_y} should be transparent at x={x}");
}
assert_eq!(canvas.width(), CANVAS_W);
assert_eq!(canvas.height(), CANVAS_H);
// Corner is dark fill.
assert_eq!(canvas.get_pixel(2, CANVAS_H - 2).0, BG.0);
// Center of the disc is not the background fill (something was
// painted there).
let center = canvas.get_pixel(CANVAS_W / 2, LOGO_TOP_MARGIN + 100);
assert_ne!(center.0, BG.0, "default mark should paint over the field");
}
#[test]
fn webp_or_jpeg_input_is_accepted_and_transcoded_to_png() {
// Encode a JPEG and confirm the compositor decodes it and emits
// a valid PNG (iPXE only eats PNG, so transcoding is the point).
let img: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_pixel(80, 80, Rgb([90, 90, 90]));
let mut jpeg = Vec::new();
DynamicImage::ImageRgb8(img)
.write_to(&mut Cursor::new(&mut jpeg), ImageFormat::Jpeg)
.unwrap();
let out = compose_pxe_background(Some(&jpeg)).unwrap();
// Output must be a PNG (magic bytes) of canvas size.
assert_eq!(&out[..8], b"\x89PNG\r\n\x1a\n");
let img = image::load_from_memory(&out).unwrap();
assert_eq!(img.width(), CANVAS_W);
}
#[test]
fn unsupported_bytes_returns_error_not_panic() {
let r = compose_pxe_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());
}
#[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
+57 -49
View File
@@ -15,18 +15,31 @@ use tokio::io::AsyncWriteExt;
/// Where the bytes for an ISO actually live.
///
/// The default is `Local` — uploaded ISOs sit in `<iso_dir>/<id>.iso`.
/// `Nfs` entries point at a file inside a remote share that the
/// `NfsManager` is keeping mounted. We resolve the on-disk path lazily
/// in [`IsoStore::iso_path_for`] using the `nfs_root` set at startup.
/// `Local` — uploaded ISO, sits at `<iso_dir>/<id>.iso`.
/// `Smb` (v0.4.65) — remote SMB share, streamed via Samba's
/// userspace `smbclient` CLI subprocess. No kernel mount, no local
/// 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.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum IsoSource {
#[default]
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 {
mount_id: String,
/// Path relative to the mount point — typically just the filename.
share_id: String,
/// Filename at the export root.
relative_path: String,
},
}
@@ -164,10 +177,6 @@ struct Inner {
#[derive(Debug, Clone)]
pub struct IsoStore {
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>>,
}
@@ -175,17 +184,10 @@ impl IsoStore {
pub fn new(iso_dir: PathBuf) -> Self {
Self {
iso_dir: Arc::new(iso_dir),
nfs_root: Arc::new(RwLock::new(None)),
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<()> {
tokio::fs::create_dir_all(self.iso_dir.as_path()).await?;
Ok(())
@@ -281,33 +283,33 @@ impl IsoStore {
self.inner.read().isos.get(id).cloned()
}
/// Resolve an ISO id to its on-disk path, if any. For local entries
/// this is `<iso_dir>/<id>.iso`; for NFS entries it's
/// `<nfs_root>/<mount_id>/<relative_path>`. Returns None if the file
/// is missing or the source isn't resolvable (e.g. NFS share
/// unmounted).
/// Resolve an ISO id to its on-disk path, if any. For local
/// (uploaded) ISOs this is `<iso_dir>/<id>.iso`. For SMB-sourced
/// ISOs there is no on-disk path — the HTTP handler must stream
/// via `SmbShareManager::stream_iso` instead. Returns `None` for
/// SMB sources or when the file is missing.
pub fn iso_path_for(&self, id: &str) -> Option<PathBuf> {
let meta = self.get(id)?;
let path = match &meta.source {
IsoSource::Local => self.iso_path(id),
IsoSource::Nfs {
mount_id,
relative_path,
} => {
let root = self.nfs_root.read().clone()?;
root.join(mount_id).join(relative_path)
match &meta.source {
IsoSource::Local => {
let path = self.iso_path(id);
if path.exists() {
Some(path)
} else {
None
}
}
};
if path.exists() {
Some(path)
} else {
None
// SMB and NFS sources have no local path — they're
// streamed in-process. Callers must inspect the source
// kind first and dispatch to the appropriate share
// manager.
IsoSource::Smb { .. } | IsoSource::Nfs { .. } => None,
}
}
/// Delete an ISO and its sidecar metadata. Only acts on local ISOs;
/// for NFS-backed ISOs the operator must remove the file from the
/// share or unmount the NFS share entirely.
/// Delete an ISO and its sidecar metadata. Only acts on local
/// (uploaded) ISOs; for SMB-backed ISOs the operator must remove
/// the file from the share or unregister the share entirely.
pub async fn delete(&self, id: &str) -> Result<()> {
let meta = self.get(id);
let is_local = matches!(
@@ -324,10 +326,10 @@ impl IsoStore {
Ok(())
}
/// Register an externally-sourced ISO (e.g. NFS-mounted). Used by
/// `NfsManager` after walking a freshly-mounted share. We do **not**
/// persist a `meta.json` on disk for these — the source of truth is
/// the share itself, and the NFS manager re-scans on startup.
/// Register an externally-sourced ISO (SMB share, etc.). Used by
/// `SmbShareManager` after listing a share. We do **not** persist
/// a `meta.json` on disk for these — the source of truth is the
/// share itself, and the manager re-scans on startup.
pub fn register_external(
&self,
id: String,
@@ -352,14 +354,20 @@ impl IsoStore {
self.inner.write().isos.insert(id, meta);
}
/// Drop every entry that belongs to `mount_id`. Used by the NFS
/// manager when an operator removes a share, or before re-scanning
/// to clean out stale entries.
pub fn drop_external_source(&self, mount_id: &str) {
/// Drop every entry that belongs to `share_id`. Used by the SMB
/// and NFS share managers when an operator removes a share, or
/// before re-scanning to clean out stale entries. The same id
/// 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();
g.isos.retain(
|_, m| !matches!(&m.source, IsoSource::Nfs { mount_id: mid, .. } if mid == mount_id),
);
g.isos.retain(|_, m| match &m.source {
IsoSource::Smb { share_id: sid, .. } | IsoSource::Nfs { share_id: sid, .. } => {
sid != share_id
}
IsoSource::Local => true,
});
}
/// Set or clear an ISO's boot password.
+29 -9
View File
@@ -9,7 +9,7 @@ use openpxe_core::{
};
use openpxe_dhcp_proxy::DhcpProxyServer;
use openpxe_http_api::{build_router, AppState};
use openpxe_iso_store::{IsoStore, NfsManager, SmbManager};
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager};
use openpxe_tftp::TftpServer;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
@@ -107,6 +107,7 @@ async fn main() -> anyhow::Result<()> {
let branding = openpxe_core::BrandingStore::load_or_default(&config.paths.work_dir);
let admin = openpxe_core::AdminStore::load_or_default(&config.paths.work_dir);
let sso = openpxe_core::SsoStore::load_or_default(&config.paths.work_dir);
let notify = openpxe_core::NotifyStore::load_or_default(&config.paths.work_dir);
let sessions = openpxe_http_api::auth::SessionStore::default();
let metrics = Metrics::new();
@@ -119,13 +120,30 @@ async fn main() -> anyhow::Result<()> {
let _ = smb.start();
}
// NFS manager. The mount root has to be set on the IsoStore *before*
// we replay any persisted mounts, otherwise an in-memory IsoMeta
// pointing at an NFS source can't resolve to a path.
let nfs = NfsManager::new(&config.paths.work_dir, iso_store.clone());
iso_store.set_nfs_root(nfs.mount_root());
if let Err(e) = nfs.load_and_remount().await {
tracing::warn!(target: "openpxe::nfs", "could not reload NFS mounts: {e}");
// v0.4.65: SMB share manager — Samba `smbclient` userspace
// consumer. Replaces the kernel-mount NFS path that v0.4.64
// shipped; that didn't work on hosts whose kernel lacked the nfs
// client modules (Unraid is the dominant case). `smbclient` does
// the SMB protocol entirely in userspace over TCP and works in
// any container regardless of capabilities or kernel modules.
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}"
);
}
// Sniff network details for the Network tab. None of these are
@@ -150,9 +168,11 @@ async fn main() -> anyhow::Result<()> {
admin: admin.clone(),
sessions: sessions.clone(),
sso: sso.clone(),
notify: notify.clone(),
metrics: metrics.clone(),
smb: Some(smb.clone()),
nfs: nfs.clone(),
smb_shares: smb_shares.clone(),
nfs_shares: nfs_shares.clone(),
uploads: openpxe_http_api::uploads::UploadSessions::default(),
log_bus: log_bus.clone(),
started_at: time::OffsetDateTime::now_utc(),
+82 -1
View File
@@ -34,9 +34,18 @@
--topbar-h: 56px;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--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"] {
color-scheme: light;
/* Light palette — high-contrast neutral, accent unchanged for brand
consistency. Designed against Netbox Labs's reference screenshot:
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;
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 a {
display: flex; align-items: center; gap: 10px;
@@ -304,6 +327,23 @@ label.field textarea {
font-size: 14px; line-height: 1.4;
box-shadow: none; -webkit-appearance: none; appearance: none;
}
/* v0.4.63: with `appearance: none`, the native <select> dropdown arrow
disappears, which makes the "Metadata source" pick-list look like a
plain (and slightly squished) text input. Paint our own chevron via
background-image so the control still reads as a dropdown, and reserve
right-padding for it. The data-URI SVG inherits currentColor via the
`stroke` attribute so the arrow follows light/dark theme without a
second declaration. */
label.field select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%239aa0a6' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><polyline points='1.5,1.5 6,6 10.5,1.5'/></svg>");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 11px 7px;
padding-right: 30px;
}
:root[data-theme="light"] label.field select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%235a6377' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><polyline points='1.5,1.5 6,6 10.5,1.5'/></svg>");
}
label.field input:focus, label.field select:focus, label.field textarea:focus {
outline: none; border-color: var(--accent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
@@ -313,7 +353,40 @@ label.check {
padding: 8px 10px; margin-bottom: 6px;
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 ────────────────────────────────────────────────────── */
@@ -554,6 +627,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 .name { font-size: 17px; font-weight: 600; letter-spacing: 0.2px; color: var(--fg); }
/* v0.5.0: FleetDM-style custom logo on the login/setup card — the
uploaded logo spans the card header and the "OpenPXE" wordmark is
dropped (the logo is the brand). Matches the sidebar treatment. */
.auth-card .brand-row.has-custom-logo { justify-content: center; gap: 0; margin-bottom: 22px; }
.auth-card .brand-row.has-custom-logo img {
width: auto; height: 52px; max-width: 240px;
object-fit: contain; object-position: center;
}
.auth-card h2 {
margin: 0 0 6px; font-size: 16px; font-weight: 600; color: var(--fg);
}
+572 -144
View File
@@ -116,6 +116,46 @@
return root;
}
// Disk-space card. Free + used + total for the volume hosting the ISO
// directory, with a coloured bar. Warns at 80% and goes red at 95% so
// the operator sees the runway shrinking before uploads start failing
// with ENOSPC. Shared by the Storage tab and the Dashboard (v0.4.68).
function diskSpaceCard(disk) {
const total = Number(disk.total_bytes || 0);
const avail = Number(disk.available_bytes || 0);
const used = Number(disk.used_bytes || 0);
const pctUsed = total > 0 ? (used / total) * 100 : 0;
let barClass = 'diskbar';
if (pctUsed >= 95) barClass += ' full';
else if (pctUsed >= 80) barClass += ' warn';
return el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Disk space'),
el('span', {class:'sub'},
total > 0 ? (pctUsed.toFixed(1) + '% used') : 'unavailable'),
]),
el('div', {class:'body'}, [
el('div', {style:'color:var(--fg-dim);font-size:12px;word-break:break-all'},
disk.path ? ('Volume: ' + disk.path) : 'Volume path unknown'),
el('div', {class: barClass},
el('div', {class:'fill',
style:'width:' + Math.min(100, pctUsed).toFixed(1) + '%'})),
el('div', {class:'disk-meta'}, [
el('span', {}, ['Used ', el('strong', {}, fmtBytes(used))]),
el('span', {}, ['Free ', el('strong', {}, fmtBytes(avail))]),
el('span', {}, ['Total ', el('strong', {}, fmtBytes(total))]),
]),
pctUsed >= 95
? el('p', {class:'msg err', style:'margin-top:10px'},
'⚠ Less than 5% free. Remove old ISOs or grow the volume before uploading more.')
: (pctUsed >= 80
? el('p', {class:'msg', style:'color:var(--warn);margin-top:10px'},
'Volume is getting full. Consider pruning old ISOs.')
: null),
]),
]);
}
// Categorize an ISO row's "bootable now" status — drives the amber
// tint borrowed from Bootimus v0.1.62. Returns {ok, reason}.
function bootability(iso, settings) {
@@ -142,6 +182,13 @@
const isos = await getJSON('/api/isos');
const clients = (await getJSON('/api/clients')).clients || [];
const entries = (await getJSON('/api/queue')).entries || [];
// v0.4.68: surface the same disk-space card the Storage tab shows,
// so operators see capacity at a glance from the landing page.
// Tolerate the endpoint being unavailable (e.g. statvfs failure)
// the same way the Storage tab does.
const disk = await getJSON('/api/storage/disk').catch(() => ({
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
}));
const ipxeOk = (status.ipxe_assets || []).length > 0;
const stats = el('div', {class: 'statstrip'}, [
@@ -167,7 +214,11 @@
el('div', {class: 'trend'},
isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' +
isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux · ' +
(status.nfs_active || 0) + ' NFS active'),
// v0.4.67: count both protocols. Label generically since
// operators may be using one, the other, or both.
((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0)) +
' remote share' +
(((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0)) === 1 ? '' : 's')),
])),
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
el('div', {class: 'label'}, 'Uptime'),
@@ -218,7 +269,7 @@
'⚠ ' + i.filename + ' — ' + b.reason)))
]) : null;
return el('div', {class:'grid'}, [stats, recentBlock, problemsBlock].filter(Boolean));
return el('div', {class:'grid'}, [stats, diskSpaceCard(disk), recentBlock, problemsBlock].filter(Boolean));
},
network: async () => {
@@ -342,13 +393,22 @@
},
storage: async () => {
const [isos, settings, nfsRes, disk] = await Promise.all([
getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/nfs'),
// v0.4.65: kernel-mount NFS replaced with userspace SMB via
// smbclient — works in any container regardless of host kernel
// modules or capabilities.
// v0.4.67: NFSv3 added back as an in-process Rust client
// (nfs3_client crate). Both protocols available side-by-side;
// operators pick whichever their NAS prefers.
const [isos, settings, smbRes, nfsRes, disk] = await Promise.all([
getJSON('/api/isos'), getJSON('/api/settings'),
getJSON('/api/smb-shares'),
getJSON('/api/nfs-shares'),
getJSON('/api/storage/disk').catch(() => ({
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
})),
]);
const mounts = nfsRes.mounts || [];
const shares = smbRes.shares || [];
const nfsShares = nfsRes.shares || [];
// ── Upload card ──
const drop = el('div', {class:'drop', id:'drop'}, [
@@ -450,7 +510,7 @@
}
}
// ── ISO table (mixed local + NFS) ──
// ── ISO table (mixed local + SMB) ──
// Each row gets a "Password" cell that toggles a small inline
// editor (a checkbox + a password field + Save button) inside the
// *next* row of the table. Keeps the markup flat and avoids the
@@ -458,7 +518,14 @@
const rowsAndEditors = [];
isos.forEach(i => {
const b = bootability(i, settings);
// v0.4.65: SMB userspace consumer (smbclient).
// v0.4.67: NFS back as in-process Rust client (nfs3_client).
// Both render with the same "remote" badge colour — they
// share the same "on a remote share, can't be deleted from
// here" semantics in the UI.
const isSmb = i.source && i.source.kind === 'smb';
const isNfs = i.source && i.source.kind === 'nfs';
const isRemote = isSmb || isNfs;
const protectedNow = !!i.password_hash;
// The inline editor row is hidden by default; the Password
@@ -578,8 +645,9 @@
]),
el('td', {class:'num'}, fmtBytes(i.size_bytes)),
el('td', {},
el('span', {class:'src-badge' + (isNfs ? ' nfs' : '')},
isNfs ? ('nfs:' + i.source.mount_id) : 'local')),
el('span', {class:'src-badge' + (isRemote ? ' nfs' : '')},
isSmb ? ('smb:' + i.source.share_id)
: isNfs ? ('nfs:' + i.source.share_id) : 'local')),
el('td', {},
protectedNow
? el('span', {class:'tag accent'}, 'protected')
@@ -589,8 +657,12 @@
el('button', {class:'ghost', style:'margin-right:6px', onclick: () => {
editorRow.style.display = (editorRow.style.display === 'none') ? '' : 'none';
}}, protectedNow ? 'Password ✎' : 'Set password'),
isNfs
? el('span', {class:'tag', style:'opacity:.6'}, 'on NFS')
isRemote
// v0.4.65/v0.4.67: remote-sourced ISOs live on the
// share — OpenPXE doesn't own those bytes. Surface a
// tag instead of a destructive button.
? el('span', {class:'tag', style:'opacity:.6'},
isSmb ? 'on SMB' : 'on NFS')
: el('button', {class:'danger', onclick: async () => {
if (!confirm('Remove this image?')) return;
await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'});
@@ -610,96 +682,166 @@
])),
el('tbody', {}, rowsAndEditors),
])
: el('div', {class:'empty'}, 'No images yet. Upload an ISO or mount an NFS share.');
: el('div', {class:'empty'}, 'No images yet. Upload an ISO or add an SMB share.');
// ── NFS section ──
const nfsMsg = el('div', {class:'msg'});
const nfsServer = el('input', {type:'text', placeholder:'10.0.0.20'});
const nfsExport = el('input', {type:'text', placeholder:'/srv/isos'});
const nfsVer = el('select', {}, [
el('option', {value:'v41'}, 'NFSv4.1 (default)'),
el('option', {value:'v3'}, 'NFSv3'),
]);
const nfsRo = el('input', {type:'checkbox'}); nfsRo.checked = true;
const addNfs = el('button', {onclick: async () => {
if (!nfsServer.value || !nfsExport.value) {
nfsMsg.textContent = 'Server and export are required.'; nfsMsg.className='msg err'; return;
// ── SMB shares section (v0.4.65) ──
// Replaces the kernel-mount NFS card. SMB shares are consumed
// in userspace via Samba's `smbclient` CLI — no kernel modules,
// no CAP_SYS_ADMIN, works in any container. This is the same
// approach Bootimus uses.
const smbMsg = el('div', {class:'msg'});
const smbServer = el('input', {type:'text', placeholder:'192.168.1.51'});
const smbShare = el('input', {type:'text', placeholder:'isos'});
const smbGuest = el('input', {type:'checkbox'}); smbGuest.checked = true;
const smbUser = el('input', {type:'text', placeholder:'(disabled when Guest)'});
const smbPass = el('input', {type:'password', placeholder:'(disabled when Guest)'});
// Toggle username/password fields based on the Guest checkbox so
// operators don't get confused about which fields matter.
const syncAuthDisabled = () => {
smbUser.disabled = smbGuest.checked;
smbPass.disabled = smbGuest.checked;
smbUser.style.opacity = smbGuest.checked ? '0.55' : '1';
smbPass.style.opacity = smbGuest.checked ? '0.55' : '1';
};
smbGuest.addEventListener('change', syncAuthDisabled);
syncAuthDisabled();
const addSmb = el('button', {onclick: async () => {
if (!smbServer.value || !smbShare.value) {
smbMsg.replaceChildren(document.createTextNode('Server and share name are required.'));
smbMsg.className='msg err'; return;
}
nfsMsg.textContent = 'Mounting…'; nfsMsg.className = 'msg';
const r = await postJSON('/api/nfs', {
server: nfsServer.value, export: nfsExport.value,
version: nfsVer.value, read_only: nfsRo.checked,
});
if (!smbGuest.checked && !smbUser.value) {
smbMsg.replaceChildren(document.createTextNode('Username is required when Guest is unchecked.'));
smbMsg.className='msg err'; return;
}
smbMsg.replaceChildren(document.createTextNode('Connecting…'));
smbMsg.className = 'msg';
const body = {
server: smbServer.value,
share: smbShare.value,
guest: smbGuest.checked,
};
if (!smbGuest.checked) {
body.username = smbUser.value;
body.password = smbPass.value;
}
const r = await postJSON('/api/smb-shares', body);
if (r.ok) {
nfsMsg.textContent = 'Mounted.'; nfsMsg.className = 'msg ok';
smbMsg.replaceChildren(document.createTextNode('Connected.'));
smbMsg.className = 'msg ok';
render('storage');
} else {
const t = await r.text();
nfsMsg.textContent = 'Mount failed: ' + t; nfsMsg.className = 'msg err';
// The API returns a structured {error, stderr, hint} JSON
// body on failure so the raw smbclient error and the
// actionable hint render as two distinct lines.
let bodyJson = null;
let raw = null;
try { bodyJson = await r.clone().json(); }
catch (_) { raw = await r.text().catch(()=> 'connect failed'); }
const msg = bodyJson && bodyJson.error ? bodyJson.error : (raw || 'connect failed');
const hint = bodyJson && bodyJson.hint;
const parts = [el('div', {}, [
el('strong', {}, 'Connect failed: '),
document.createTextNode(msg),
])];
if (hint) {
parts.push(el('div', {style:'margin-top:6px;opacity:.78;font-size:12px'}, hint));
}
smbMsg.replaceChildren(...parts);
smbMsg.className = 'msg err';
}
}}, 'Mount share');
}}, 'Add share');
const nfsRows = mounts.length ? mounts.map(m => el('div', {class: 'nfs-row' + (m.mounted ? '' : ' down')}, [
el('span', {class: 'dot ' + (m.mounted ? 'ok' : 'err')}),
const smbRows = shares.length ? shares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
el('div', {}, [
el('div', {class:'id'}, m.server + ':' + m.export),
el('div', {class:'id'}, '//' + m.server + '/' + m.share),
el('div', {class:'meta'},
(m.version === 'v3' ? 'NFSv3' : 'NFSv4.1') + ' · ' +
(m.read_only ? 'read-only' : 'read-write') + ' · ' +
(m.mounted ? m.iso_count + ' isos' : 'not mounted')),
(m.guest ? 'guest' : ('user: ' + (m.username || '?'))) + ' · ' +
(m.reachable ? m.iso_count + ' isos' : 'not reachable')),
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
]),
el('button', {class:'ghost', onclick: async () => {
const r = await postJSON('/api/nfs/' + encodeURIComponent(m.id) + '/scan', {});
const r = await postJSON('/api/smb-shares/' + encodeURIComponent(m.id) + '/scan', {});
if (r.ok) render('storage');
}}, 'Re-scan'),
el('button', {class:'danger', onclick: async () => {
if (!confirm('Unmount ' + m.server + ':' + m.export + '?')) return;
await fetch('/api/nfs/' + encodeURIComponent(m.id), {method:'DELETE'});
if (!confirm('Forget //' + m.server + '/' + m.share + '?')) return;
await fetch('/api/smb-shares/' + encodeURIComponent(m.id), {method:'DELETE'});
render('storage');
}}, 'Unmount'),
}}, 'Remove'),
el('span'),
])) : [el('div', {class:'empty'}, 'No NFS shares mounted.')];
])) : [el('div', {class:'empty'}, 'No SMB shares configured.')];
// Disk-space card. Free + used + total for the volume hosting the
// ISO directory, with a coloured bar. Warns at 80% and goes red at
// 95% so the operator sees the runway shrinking before uploads
// start failing with ENOSPC.
const total = Number(disk.total_bytes || 0);
const avail = Number(disk.available_bytes || 0);
const used = Number(disk.used_bytes || 0);
const pctUsed = total > 0 ? (used / total) * 100 : 0;
let barClass = 'diskbar';
if (pctUsed >= 95) barClass += ' full';
else if (pctUsed >= 80) barClass += ' warn';
const diskCard = el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Disk space'),
el('span', {class:'sub'},
total > 0
? (pctUsed.toFixed(1) + '% used')
: 'unavailable'),
// ── NFS shares section (v0.4.67) ──
// Parallel to SMB shares above. The NFSv3 client is in-process
// (nfs3_client crate) so NFS-sourced ISOs support HTTP Range
// requests — SMB-sourced ones don't (smbclient CLI can't seek
// mid-stream). Otherwise the UX is identical: server + export,
// submit, scan, remove.
const nfsMsg = el('div', {class:'msg'});
const nfsServerIn = el('input', {type:'text', placeholder:'10.0.0.5'});
const nfsExportIn = el('input', {type:'text', placeholder:'/srv/isos'});
const addNfs = el('button', {style:'margin-top:14px', onclick: async () => {
if (!nfsServerIn.value || !nfsExportIn.value) {
nfsMsg.replaceChildren(document.createTextNode('Server and export are required.'));
nfsMsg.className = 'msg err'; return;
}
nfsMsg.replaceChildren(document.createTextNode('Connecting…'));
nfsMsg.className = 'msg';
const r = await postJSON('/api/nfs-shares', {
server: nfsServerIn.value,
export: nfsExportIn.value,
});
if (r.ok) {
nfsMsg.replaceChildren(document.createTextNode('Connected.'));
nfsMsg.className = 'msg ok';
render('storage');
} else {
// Structured {error, stderr, hint} same as SMB.
let bodyJson = null;
let raw = null;
try { bodyJson = await r.clone().json(); }
catch (_) { raw = await r.text().catch(()=> 'connect failed'); }
const msg = bodyJson && bodyJson.error ? bodyJson.error : (raw || 'connect failed');
const hint = bodyJson && bodyJson.hint;
const parts = [el('div', {}, [
el('strong', {}, 'Connect failed: '),
document.createTextNode(msg),
])];
if (hint) {
parts.push(el('div', {style:'margin-top:6px;opacity:.78;font-size:12px'}, hint));
}
nfsMsg.replaceChildren(...parts);
nfsMsg.className = 'msg err';
}
}}, 'Add share');
const nfsRows = nfsShares.length ? nfsShares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
el('div', {}, [
el('div', {class:'id'}, m.server + ':' + m.export),
el('div', {class:'meta'},
'NFSv3 · ' +
(m.reachable ? m.iso_count + ' isos' : 'not reachable')),
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
]),
el('div', {class:'body'}, [
el('div', {style:'color:var(--fg-dim);font-size:12px;word-break:break-all'},
disk.path ? ('Volume: ' + disk.path) : 'Volume path unknown'),
el('div', {class: barClass},
el('div', {class:'fill',
style:'width:' + Math.min(100, pctUsed).toFixed(1) + '%'})),
el('div', {class:'disk-meta'}, [
el('span', {}, ['Used ', el('strong', {}, fmtBytes(used))]),
el('span', {}, ['Free ', el('strong', {}, fmtBytes(avail))]),
el('span', {}, ['Total ', el('strong', {}, fmtBytes(total))]),
]),
pctUsed >= 95
? el('p', {class:'msg err', style:'margin-top:10px'},
'⚠ Less than 5% free. Remove old ISOs or grow the volume before uploading more.')
: (pctUsed >= 80
? el('p', {class:'msg', style:'color:var(--warn);margin-top:10px'},
'Volume is getting full. Consider pruning old ISOs.')
: null),
]),
]);
el('button', {class:'ghost', onclick: async () => {
const r = await postJSON('/api/nfs-shares/' + encodeURIComponent(m.id) + '/scan', {});
if (r.ok) render('storage');
}}, 'Re-scan'),
el('button', {class:'danger', onclick: async () => {
if (!confirm('Forget ' + m.server + ':' + m.export + '?')) return;
await fetch('/api/nfs-shares/' + encodeURIComponent(m.id), {method:'DELETE'});
render('storage');
}}, 'Remove'),
el('span'),
])) : [el('div', {class:'empty'}, 'No NFS shares configured.')];
const diskCard = diskSpaceCard(disk);
return el('div', {class:'grid'}, [
diskCard,
@@ -709,34 +851,75 @@
]),
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'NFS shares'),
el('span', {class:'sub'}, mounts.length + ' configured'),
el('h2', {}, 'SMB shares'),
el('span', {class:'sub'}, shares.length + ' configured'),
]),
el('div', {class:'body'}, [
el('div', {class:'form-row'}, [
el('div', {class:'form-row cols-2'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'SMB server'),
smbServer,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Share name'),
smbShare,
]),
]),
el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [
el('label', {class:'check'}, [
smbGuest, el('span', {}, 'Guest (anonymous read)'),
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Username'),
smbUser,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Password'),
smbPass,
]),
]),
addSmb, smbMsg,
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, smbRows),
el('p', {class:'msg', style:'margin-top:14px'},
'SMB shares are read in userspace via Sambas smbclient — ' +
'no kernel modules, no CAP_SYS_ADMIN, works in any container ' +
'(Unraid, OpenShift restricted SCC, plain Docker, etc.). Most NAS ' +
'appliances expose ISO libraries as guest-readable; check the box ' +
'above when thats the case. ISOs are streamed on demand at PXE ' +
'boot time — no local cache, no double disk usage.'),
]),
]),
// v0.4.67: NFS shares card sits right below SMB so operators
// can see both protocols at a glance. The form is simpler
// (no auth) because NFSv3 access control is by client IP on
// the server side, not by client-supplied credentials.
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'NFS shares'),
el('span', {class:'sub'}, nfsShares.length + ' configured'),
]),
el('div', {class:'body'}, [
el('div', {class:'form-row cols-2'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'NFS server'),
nfsServer,
nfsServerIn,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Export path'),
nfsExport,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Version'),
nfsVer,
]),
el('label', {class:'check', style:'margin-top:18px'}, [
nfsRo, el('span', {}, 'Read-only'),
nfsExportIn,
]),
]),
addNfs, nfsMsg,
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, nfsRows),
el('p', {class:'msg', style:'margin-top:14px'},
'Mounting NFS inside a container requires CAP_SYS_ADMIN and the ' +
'mount.nfs binary (bundled in the default Docker image). On ' +
'OpenShift, your SCC must allow CAP_SYS_ADMIN or you can run ' +
'NFS mounts as a CSI driver outside the pod.'),
'NFSv3 shares are read in-process via a pure-Rust client — ' +
'no kernel modules, no mount.nfs, no CAP_SYS_ADMIN. Works in ' +
'every container the SMB path works in (Unraid included). ' +
'NFSv3 auth is AUTH_SYS only; gate access on the server side ' +
'by allowing this OpenPXE hosts IP in the export list. ' +
'ISOs are streamed on demand and HTTP Range requests work — ' +
'NFSv3 READ3 takes an explicit offset, so clients can seek ' +
'into a 5 GB ISO without reading what comes before.'),
]),
]),
el('div', {class:'card'}, [
@@ -790,18 +973,37 @@
}
}}, 'Bind MAC to target');
const rows = hosts.map(h => el('tr', {}, [
el('td', {class:'mono'}, h.mac),
el('td', {}, h.label || el('span', {class:'tag'}, '(unlabeled)')),
el('td', {class:'mono'}, h.target),
el('td', {}, fmtAgo(h.updated_at)),
el('td', {style:'text-align:right'},
el('button', {class:'danger', onclick: async () => {
if (!confirm('Remove binding for ' + h.mac + '?')) return;
await fetch('/api/hosts/' + encodeURIComponent(h.mac), {method:'DELETE'});
render('hosts');
}}, 'Remove')),
]));
const rows = hosts.map(h => {
// v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole
// table is bound hosts). The button reports its own state inline
// so there's no shared toast to thread through.
const wakeBtn = el('button', {class:'ghost', onclick: async () => {
wakeBtn.disabled = true;
const original = wakeBtn.textContent;
wakeBtn.textContent = 'Waking…';
try {
const r = await postJSON('/api/hosts/' + encodeURIComponent(h.mac) + '/wol', {});
wakeBtn.textContent = r.ok ? 'Sent ✓' : 'Failed';
} catch (e) {
wakeBtn.textContent = 'Failed';
}
setTimeout(() => { wakeBtn.textContent = original; wakeBtn.disabled = false; }, 2500);
}}, 'Wake');
return el('tr', {}, [
el('td', {class:'mono'}, h.mac),
el('td', {}, h.label || el('span', {class:'tag'}, '(unlabeled)')),
el('td', {class:'mono'}, h.target),
el('td', {}, fmtAgo(h.updated_at)),
el('td', {style:'text-align:right;white-space:nowrap'}, [
wakeBtn,
el('button', {class:'danger', style:'margin-left:8px', onclick: async () => {
if (!confirm('Remove binding for ' + h.mac + '?')) return;
await fetch('/api/hosts/' + encodeURIComponent(h.mac), {method:'DELETE'});
render('hosts');
}}, 'Remove'),
]),
]);
});
const table = hosts.length
? el('table', {}, [
@@ -1002,9 +1204,8 @@
},
settings: async () => {
const [status, docs, me, sso] = await Promise.all([
const [status, me, sso] = await Promise.all([
getJSON('/api/status'),
getJSON('/api/docs').catch(() => ({ groups: [] })),
getJSON('/api/me').catch(() => ({})),
getJSON('/api/sso').catch(() => ({
enabled:false, idp_name:'', metadata:'', metadata_url:'',
@@ -1025,7 +1226,11 @@
const newPwConfirm = el('input', {type:'password', autocomplete:'new-password',
placeholder: 'confirm new password'});
const accountMsg = el('div', {class:'msg', style:'margin-top:8px'});
const accountSave = el('button', {onclick: async () => {
// v0.4.63: explicit top margin so the action button sits clearly
// beneath the input row instead of butting against the password
// fields. Mirrors the `Save SSO settings` button below for visual
// parity between the two settings cards.
const accountSave = el('button', {style:'margin-top:6px', onclick: async () => {
accountMsg.textContent = ''; accountMsg.className = 'msg';
if (!currentPw.value) {
accountMsg.textContent = 'Current password is required.';
@@ -1130,29 +1335,39 @@
el('option', {value:'xml'}, 'Metadata XML'),
]);
ssoMode.value = sso.metadata && !sso.metadata_url ? 'xml' : 'url';
// v0.4.63: the IdP metadata URL now sits inside the 4-col header
// grid as column 4, so the SSO row is column-for-column aligned with
// the Administrator account row above. When the operator switches
// to XML mode, column 4 collapses (display:none) and the multi-line
// XML textarea takes its own full-width row below — there's no way
// to fit a 6-row textarea into a single grid cell without making
// the rest of the row look stretched.
const urlWrap = el('label', {class:'field'}, [
el('span', {class:'name'}, 'IdP metadata URL'),
ssoUrl,
el('span', {class:'hint'},
'OpenPXE will fetch this URL once SSO sign-in lands; v0.4.6 just stores it.'),
]);
const xmlWrap = el('label', {class:'field'}, [
const xmlWrap = el('label', {class:'field', style:'margin-top:14px'}, [
el('span', {class:'name'}, 'IdP metadata XML'),
ssoXml,
el('span', {class:'hint'},
'Paste the raw <EntityDescriptor>…</EntityDescriptor> document from your IdP.'),
]);
// Hint that used to live under the URL field; surfaced once below
// the whole row so it doesn't compete with the in-grid layout.
const urlHint = el('p', {class:'msg', style:'margin-top:10px;margin-bottom:0'},
'OpenPXE will fetch the metadata URL once SSO sign-in lands; v0.4.63 stores it.');
const refreshSsoFields = () => {
if (ssoMode.value === 'url') {
urlWrap.style.display = ''; xmlWrap.style.display = 'none';
urlHint.style.display = '';
} else {
urlWrap.style.display = 'none'; xmlWrap.style.display = '';
urlHint.style.display = 'none';
}
};
ssoMode.onchange = refreshSsoFields;
refreshSsoFields();
const ssoMsg = el('div', {class:'msg', style:'margin-top:8px'});
const ssoSave = el('button', {onclick: async () => {
const ssoSave = el('button', {style:'margin-top:16px', onclick: async () => {
ssoMsg.textContent = ''; ssoMsg.className = 'msg';
const payload = {
enabled: ssoEnabled.checked,
@@ -1193,32 +1408,35 @@
ssoEnabled,
el('span', {}, 'Enable single sign-on'),
]),
// 3-column header strip: display name, logo URL, metadata
// source. All three controls inherit the same border/padding/
// focus chrome from the global `label.field input/select`
// rule, so they line up cleanly. Below: the active source
// field (URL or XML) spans the full width.
el('div', {class:'form-row cols-3'}, [
// v0.4.63: 4-column form-row that matches the Administrator
// account card above column-for-column — display name / logo
// URL / metadata source / metadata URL. All four controls share
// the same `label.field` chrome so they line up cleanly. When
// the operator picks "Metadata XML" the URL column collapses
// and the multi-line textarea drops below the row.
el('div', {class:'form-row'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'IdP display name'),
ssoName,
el('span', {class:'hint'}, '"Sign in with X" label on the login screen.'),
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'IdP logo URL'),
ssoLogo,
el('span', {class:'hint'}, 'Optional. Shown next to the IdP name on the login button.'),
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Metadata source'),
ssoMode,
]),
urlWrap,
]),
urlWrap,
xmlWrap,
urlHint,
ssoSave, ssoMsg,
]),
]);
// Wire up + paint the initial visibility now that all elements
// referenced by `refreshSsoFields` are attached.
refreshSsoFields();
// ── Custom logo upload.
// Single-file drop-zone; PNG/SVG/JPEG/WebP/GIF up to 2 MB.
@@ -1289,10 +1507,133 @@
]),
]);
// ── API reference (always at the bottom of Settings).
// Sourced from /api/docs so the hand-curated list stays the
// single source of truth and the UI doesn't need its own copy
// baked into the JS bundle.
// v0.5.0: the API reference moved to the Advanced tab; Settings
// now holds just account / SSO / branding.
return el('div', {class:'grid'}, [accountCard, ssoCard, logoCard]);
},
// v0.5.0: Advanced settings — webhook/email notifications, and the
// API reference (relocated from the bottom of Settings).
advanced: async () => {
const [notify, docs] = await Promise.all([
getJSON('/api/notify').catch(() => ({ enabled:false, kind:'slack' })),
getJSON('/api/docs').catch(() => ({ groups: [] })),
]);
// ── Notification config ──
const nMsg = el('div', {class:'msg', style:'margin-top:12px'});
const nEnabled = el('input', {type:'checkbox'}); nEnabled.checked = !!notify.enabled;
const nKind = el('select', {}, [
el('option', {value:'slack'}, 'Slack'),
el('option', {value:'discord'}, 'Discord'),
el('option', {value:'teams'}, 'Microsoft Teams'),
el('option', {value:'smtp'}, 'Email (SMTP)'),
]);
nKind.value = notify.kind || 'slack';
const nWebhook = el('input', {type:'text', placeholder:'https://hooks.slack.com/services/…',
value: notify.webhook_url || ''});
// SMTP fields.
const sHost = el('input', {type:'text', placeholder:'smtp.example.com', value: notify.smtp_host || ''});
const sPort = el('input', {type:'number', value: String(notify.smtp_port || 587)});
const sUser = el('input', {type:'text', placeholder:'(optional)', value: notify.smtp_username || ''});
// GET returns the password as a redaction sentinel when one is
// set; show an empty field with an "(unchanged)" placeholder and
// let collectNotify() re-send the sentinel so the stored secret
// is preserved unless the operator types a new one.
const hasStoredPass = !!notify.smtp_password;
const sPass = el('input', {type:'password',
placeholder: hasStoredPass ? '•••••• (unchanged)' : '', value: ''});
const sFrom = el('input', {type:'text', placeholder:'[email protected]', value: notify.smtp_from || ''});
const sTo = el('input', {type:'text', placeholder:'[email protected]', value: notify.smtp_to || ''});
const sTls = el('input', {type:'checkbox'}); sTls.checked = !!notify.smtp_implicit_tls;
const webhookBlock = el('div', {class:'form-row'}, [
el('label', {class:'field', style:'grid-column:1 / -1'}, [
el('span', {class:'name'}, 'Incoming webhook URL'),
nWebhook,
el('span', {class:'hint'},
'Slack/Discord/Teams all use an "incoming webhook" URL you create in that app.'),
]),
]);
const smtpBlock = el('div', {}, [
el('div', {class:'form-row cols-2'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'SMTP host'), sHost]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Port'), sPort]),
]),
el('div', {class:'form-row cols-2', style:'margin-top:12px'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'Username (optional)'), sUser]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Password'), sPass]),
]),
el('div', {class:'form-row cols-2', style:'margin-top:12px'}, [
el('label', {class:'field'}, [el('span', {class:'name'}, 'From'), sFrom]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'To'), sTo]),
]),
el('label', {class:'check', style:'margin-top:12px'}, [
sTls, el('span', {}, 'Implicit TLS (port 465). Leave off for STARTTLS (587).'),
]),
]);
// Toggle which provider block shows.
const syncKind = () => {
const smtp = nKind.value === 'smtp';
webhookBlock.style.display = smtp ? 'none' : '';
smtpBlock.style.display = smtp ? '' : 'none';
};
nKind.addEventListener('change', syncKind);
syncKind();
const collectNotify = () => ({
enabled: nEnabled.checked,
kind: nKind.value,
webhook_url: nWebhook.value,
smtp_host: sHost.value,
smtp_port: Number(sPort.value) || 587,
smtp_username: sUser.value,
// Empty field + a stored password → send the sentinel so the
// server keeps it. Otherwise send whatever was typed (a new
// password, or empty to clear when none was stored).
smtp_password: (sPass.value === '' && hasStoredPass) ? '__keep__' : sPass.value,
smtp_from: sFrom.value,
smtp_to: sTo.value,
smtp_implicit_tls: sTls.checked,
});
const saveBtn = el('button', {style:'margin-top:16px', onclick: async () => {
nMsg.textContent = 'Saving…'; nMsg.className = 'msg';
const r = await putJSON('/api/notify', collectNotify());
if (r.ok) { nMsg.textContent = 'Saved.'; nMsg.className = 'msg ok'; render('advanced'); }
else { nMsg.textContent = 'Save failed: ' + (await r.text()); nMsg.className = 'msg err'; }
}}, 'Save notification settings');
const testBtn = el('button', {class:'ghost', style:'margin-top:16px;margin-left:8px',
onclick: async () => {
nMsg.textContent = 'Sending test…'; nMsg.className = 'msg';
// Save first so the test uses exactly what's on screen.
const rs = await putJSON('/api/notify', collectNotify());
if (!rs.ok) { nMsg.textContent = 'Save failed: ' + (await rs.text()); nMsg.className = 'msg err'; return; }
const r = await postJSON('/api/notify/test', {});
if (r.ok) { nMsg.textContent = 'Test notification sent — check your channel/inbox.'; nMsg.className = 'msg ok'; }
else { nMsg.textContent = 'Test failed: ' + (await r.text()); nMsg.className = 'msg err'; }
}}, 'Send test');
const notifyCard = el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Webhook notifications'),
el('span', {class:'sub'}, notify.enabled ? 'enabled' : 'disabled'),
]),
el('div', {class:'body'}, [
el('p', {class:'msg', style:'margin-bottom:14px'},
'Get pinged when a machine PXE-boots an image, a deployment is assigned, ' +
'or a host is woken. One provider at a time — pick yours, paste the URL ' +
'(or SMTP details), and Send test.'),
el('div', {class:'form-row cols-2'}, [
el('label', {class:'check'}, [nEnabled, el('span', {}, 'Enable notifications')]),
el('label', {class:'field'}, [el('span', {class:'name'}, 'Provider'), nKind]),
]),
el('div', {style:'margin-top:14px'}, [webhookBlock, smtpBlock]),
saveBtn, testBtn, nMsg,
]),
]);
// ── API reference (relocated from Settings) ──
const groups = docs.groups || [];
const apiCard = el('div', {class:'card'}, [
el('header', {}, [
@@ -1315,12 +1656,41 @@
'No API documentation returned by /api/docs.')),
]);
return el('div', {class:'grid'}, [accountCard, ssoCard, logoCard, apiCard]);
return el('div', {class:'grid'}, [notifyCard, apiCard]);
},
about: async () => {
const status = await getJSON('/api/status');
return el('div', {class:'card'}, [
// ── Check for updates ──
const updMsg = el('div', {class:'msg', style:'margin-top:10px'});
const updBtn = el('button', {onclick: async () => {
updMsg.textContent = 'Checking the OpenPXE release feed…'; updMsg.className = 'msg';
try {
const r = await getJSON('/api/updates/check');
if (r.error) {
updMsg.replaceChildren(document.createTextNode('Couldnt check: ' + r.error));
updMsg.className = 'msg err';
} else if (r.update_available) {
const parts = [document.createTextNode('Update available: '),
el('strong', {}, r.latest), document.createTextNode(' (youre on ' + r.current + '). ')];
if (r.html_url) {
parts.push(el('a', {href:r.html_url, target:'_blank', rel:'noopener noreferrer'},
'View release →'));
}
updMsg.replaceChildren(...parts);
updMsg.className = 'msg ok';
} else {
updMsg.replaceChildren(document.createTextNode(
'Youre up to date — running the latest release (' + r.current + ').'));
updMsg.className = 'msg ok';
}
} catch (e) {
updMsg.textContent = 'Couldnt reach the release feed (offline?).'; updMsg.className = 'msg err';
}
}}, 'Check for updates');
const heroCard = el('div', {class:'card'}, [
el('div', {class:'about-hero'}, [
el('h2', {}, 'OpenPXE'),
el('p', {class:'lead'},
@@ -1335,6 +1705,7 @@
el('a', {href:'https://openpxe.com/', target:'_blank', rel:'noopener noreferrer'},
'https://openpxe.com/'),
]),
el('div', {style:'margin-top:18px'}, [updBtn, updMsg]),
el('p', {class:'msg', style:'margin-top:18px'},
'iPXE is an internal implementation detail. Everything the firmware ' +
'executes is generated from the settings on these tabs — there is no ' +
@@ -1347,6 +1718,46 @@
'flip "testsigning" on a target machine.'),
]),
]);
// ── Licenses ──
const licenseCard = el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'License')),
el('div', {class:'body'}, [
el('p', {class:'msg', style:'margin-bottom:12px'}, [
'OpenPXE is dual-licensed under ',
el('strong', {}, 'MIT'), document.createTextNode(' OR '),
el('strong', {}, 'Apache License 2.0'),
document.createTextNode(
' — use it under whichever fits your organization (SPDX: MIT OR Apache-2.0).'),
]),
el('div', {class:'license-grid', style:'display:grid;grid-template-columns:1fr 1fr;gap:14px'}, [
el('div', {}, [
el('h3', {style:'margin:0 0 6px'}, 'MIT License'),
el('p', {class:'msg', style:'font-size:12px'},
'Permission is hereby granted, free of charge, to any person obtaining a copy ' +
'of this software and associated documentation files, to deal in the Software ' +
'without restriction… THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.'),
el('a', {href:'https://opensource.org/license/mit', target:'_blank', rel:'noopener noreferrer'},
'Full MIT text →'),
]),
el('div', {}, [
el('h3', {style:'margin:0 0 6px'}, 'Apache License 2.0'),
el('p', {class:'msg', style:'font-size:12px'},
'Licensed under the Apache License, Version 2.0. Includes an express grant of ' +
'patent rights from contributors. Distributed on an "AS IS" BASIS, WITHOUT ' +
'WARRANTIES OR CONDITIONS OF ANY KIND.'),
el('a', {href:'https://www.apache.org/licenses/LICENSE-2.0', target:'_blank', rel:'noopener noreferrer'},
'Full Apache 2.0 text →'),
]),
]),
el('p', {class:'msg', style:'margin-top:14px;font-size:12px;opacity:.8'},
'Bundled components keep their own licenses: iPXE (GPLv2 / UBDL), Samba smbclient, ' +
'wimtools, and the Rust crates in this build. See the source repository for the ' +
'complete NOTICE.'),
]),
]);
return el('div', {class:'grid'}, [heroCard, licenseCard]);
},
};
@@ -1359,6 +1770,7 @@
hosts: 'Hosts',
terminal: 'Terminal',
settings: 'Settings',
advanced: 'Advanced',
about: 'About',
};
@@ -1463,6 +1875,26 @@
let chipsInterval = null;
let authScreenEl = null;
let ssoConfig = null;
// v0.5.0: branding bootstrap for the pre-auth screens, populated from
// /api/me (public). Lets the login/setup cards render the same
// FleetDM-style full-width custom logo the dashboard sidebar uses.
let brandInfo = { has_custom_logo: false, logo_rev: 0 };
// Brand row for the auth screens. With a custom logo uploaded: the
// logo spans the card, no "OpenPXE" wordmark (the logo is the brand).
// Default: bundled mark + "OpenPXE".
function authBrandRow() {
const src = '/assets/logo.svg?r=' + (brandInfo.logo_rev || 0);
if (brandInfo.has_custom_logo) {
return el('div', {class:'brand-row has-custom-logo'}, [
el('img', {src, alt:'logo'}),
]);
}
return el('div', {class:'brand-row'}, [
el('img', {src, alt:''}),
el('div', {class:'name'}, 'OpenPXE'),
]);
}
function teardownAuthScreen() {
if (authScreenEl && authScreenEl.parentNode) {
@@ -1519,10 +1951,7 @@
submit.textContent = 'Sign in';
}
}}, [
el('div', {class:'brand-row'}, [
el('img', {src:'/assets/logo.svg', alt:''}),
el('div', {class:'name'}, 'OpenPXE'),
]),
authBrandRow(),
el('h2', {}, 'Sign in'),
el('p', {class:'lede'}, 'Enter your administrator credentials. Forgot them? SSH to the host and remove work_dir/auth.json — the next launch will re-prompt for setup.'),
el('label', {class:'field'}, [
@@ -1587,10 +2016,7 @@
submit.textContent = 'Create administrator';
}
}}, [
el('div', {class:'brand-row'}, [
el('img', {src:'/assets/logo.svg', alt:''}),
el('div', {class:'name'}, 'OpenPXE'),
]),
authBrandRow(),
el('h2', {}, 'First-run setup'),
el('p', {class:'lede'}, 'Welcome. Create the administrator account that will own this OpenPXE deployment. Additional users come in through SSO later.'),
el('label', {class:'field'}, [
@@ -1706,15 +2132,17 @@
let me;
try {
me = await fetch('/api/me').then(r => r.json());
// Capture branding so the auth cards render the custom logo.
brandInfo = {
has_custom_logo: !!me.has_custom_logo,
logo_rev: me.logo_rev || 0,
};
} catch (e) {
// /api/me is unauthenticated in every state — if we can't reach
// it the server is genuinely down, not an auth problem.
document.body.appendChild(el('div', {class:'auth-screen'},
el('div', {class:'auth-card'}, [
el('div', {class:'brand-row'}, [
el('img', {src:'/assets/logo.svg', alt:''}),
el('div', {class:'name'}, 'OpenPXE'),
]),
authBrandRow(),
el('h2', {}, 'Connection error'),
el('p', {class:'lede'}, 'Could not reach the OpenPXE server. Refresh once it is back up.'),
])));
+5 -4
View File
@@ -13,7 +13,7 @@
on the asset handlers, the practical caching window is one
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}}" />
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg?v={{ASSET_VERSION}}&r={{LOGO_REV}}" />
<!-- Theme is read from localStorage *before* paint to avoid the
dark→light flash on every navigation. Falls back to the OS
preference and finally to dark. -->
@@ -32,8 +32,8 @@
<body>
<div class="shell">
<aside class="sidebar">
<div class="brand">
<img src="/assets/logo.svg?v={{ASSET_VERSION}}" alt="OpenPXE" />
<div class="{{BRAND_CLASS}}">
<img src="/assets/logo.svg?v={{ASSET_VERSION}}&r={{LOGO_REV}}" alt="OpenPXE" />
<strong>OpenPXE</strong>
</div>
<nav>
@@ -53,6 +53,7 @@
</a>
<a data-view="terminal">Terminal</a>
<a data-view="settings">Settings</a>
<a data-view="advanced">Advanced</a>
<a data-view="about">About</a>
</nav>
<div class="footer">
@@ -66,7 +67,7 @@
<!-- The brand badge at the top can be overridden by operator-uploaded
logos; keep "OpenPXE v…" pinned in the footer so the backend
identity is always visible regardless of branding. -->
<div class="footer-version">OpenPXE&nbsp;v<span data-bind="version">0.4.61</span></div>
<div class="footer-version">OpenPXE&nbsp;v<span data-bind="version">0.4.63</span></div>
</div>
</aside>
+24 -1
View File
@@ -17,11 +17,34 @@
/// when we know the new one is incompatible. Combined with
/// `Cache-Control: no-cache, must-revalidate` on the asset handlers,
/// the worst-case caching window is one version.
/// * `logo_rev` is appended to the brand-mark and favicon URLs as an
/// extra `&r=…` token. Unlike `asset_version` it changes every time
/// the operator swaps the custom logo, so the top-left mark updates
/// immediately on the next page load instead of being pinned to the
/// release version (which only changes on upgrade). `index.html`
/// itself is served `no-cache`, so the fresh token lands as soon as
/// the operator reloads after an upload.
/// * `has_custom_logo` switches the sidebar brand block between the
/// bundled mark + "OpenPXE" wordmark (false) and a FleetDM-style
/// full-width custom logo with the wordmark hidden (true). Rendered
/// server-side so there's no flash of the default mark before JS runs.
#[must_use]
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
.replace("{{BASE_URL}}", base_url)
.replace("{{ASSET_VERSION}}", asset_version)
.replace("{{LOGO_REV}}", &logo_rev.to_string())
.replace("{{BRAND_CLASS}}", brand_class)
}
#[must_use]
+62 -16
View File
@@ -17,14 +17,11 @@
ARG RUST_VERSION=1.95
########## fetch iPXE binaries + wimboot ##########
# v0.4.62: kept on the boot.ipxe.org pre-builds for the moment. We
# want PNG support (so `console --picture` paints the operator's logo
# on the PXE menu) but the obvious path — adding a new `ipxe-build`
# stage that compiles iPXE from source with `IMAGE_PNG` enabled
# runs into a QEMU/gcc instability when cross-emulating x86_64 on
# 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.
# Pulls the upstream boot.ipxe.org pre-builds (no PNG support) plus
# wimboot. These cover the arches we don't build from source here:
# BIOS undionly.kpxe and i386-efi (which need a 32-bit x86 toolchain),
# and serve as the baseline that the PNG-enabled x86_64/arm64 UEFI
# binaries from the `ipxe-build` stage overlay on top of.
FROM debian:12-slim AS fetch
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
@@ -32,6 +29,36 @@ WORKDIR /src
COPY scripts/fetch-ipxe.sh scripts/fetch-ipxe.sh
RUN mkdir -p assets/ipxe && bash scripts/fetch-ipxe.sh
########## build PNG-enabled iPXE from source ##########
# v0.4.69: THE graphical-boot-menu unlock. iVentoy paints a PNG
# background on the PXE screen using stock iPXE built with
# CONSOLE_FRAMEBUFFER + IMAGE_PNG + CONSOLE_CMD; the public iPXE
# binaries omit those, so `console --picture` is a no-op on them.
# We build our own from upstream with that thin config delta.
#
# The historical blocker was cc1 segfaulting when an amd64 gcc ran
# under QEMU emulation on an arm64 host. The fix: pin this stage to
# $BUILDPLATFORM (the NATIVE builder arch — arm64 on an Apple-Silicon
# Mac, amd64 in x86 CI) and cross-compile with a real cross toolchain
# (CROSS_COMPILE=x86_64-linux-gnu-). The compiler runs native and
# emits x86_64 — no emulation, no segfault. arm64-efi builds natively.
FROM --platform=$BUILDPLATFORM debian:12-slim AS ipxe-build
# libc6-dev is REQUIRED and easy to miss under --no-install-recommends:
# iPXE's host utilities (elf2efi, zbin) compile with the native gcc and
# pull <stdint.h>; without the native libc headers gcc's #include_next
# falls through to iPXE's freestanding headers and dies on bits/stdint.h.
# The target (iPXE firmware) code is -ffreestanding/-nostdinc, so the
# x86_64 cross toolchain needs NO cross libc headers.
RUN apt-get update && apt-get install -y --no-install-recommends \
git make perl gcc binutils libc6-dev \
gcc-x86-64-linux-gnu binutils-x86-64-linux-gnu \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY scripts/build-ipxe.sh scripts/build-ipxe.sh
COPY deploy/ipxe/local/ deploy/ipxe/local/
RUN mkdir -p assets/ipxe && bash scripts/build-ipxe.sh /src/assets/ipxe
########## build openpxe ##########
FROM rust:${RUST_VERSION}-bookworm AS build
WORKDIR /src
@@ -64,7 +91,14 @@ RUN apt-get update \
# `cargo build`, which is slow and can exhaust small Colima/CI disks.
COPY Cargo.toml Cargo.lock ./
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=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
# belt-and-suspenders: cargo occasionally misses mtime-only changes on
@@ -81,23 +115,30 @@ FROM debian:12-slim AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates libcap2-bin tini gosu iproute2 \
wimtools samba nfs-common \
wimtools samba smbclient \
&& rm -rf /var/lib/apt/lists/* \
&& 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 \
&& chown -R openpxe:openpxe /var/lib/openpxe
# v0.4.5: the openpxe binary itself is now built against musl and is
# 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:
#
# wimtools - `wimlib-imagex`, used to inject startnet.cmd into boot.wim.
# samba - `smbd` serves extracted Windows install media on :445 so
# WinPE can `net use`. Guest read-only, scoped to
# /var/lib/openpxe/smb.
# nfs-common - `mount.nfs` / `mount.nfs4` for the Storage tab's NFS
# share manager. Mount requires CAP_SYS_ADMIN; without it
# mount(2) returns EPERM and the manager surfaces a clear
# error in the UI.
# /var/lib/openpxe/smb. This package provides the SERVER
# side only; the client CLI is a separate package below.
# smbclient - v0.4.66: Samba's `smbclient` userspace CLI, used by
# the Storage tab's SMB shares manager to list and stream
# 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
# tab fields (NIC name, subnet mask, default gateway).
# Tiny, always available; we don't pull in netlink crates
@@ -105,8 +146,13 @@ RUN apt-get update \
# gosu - drops privileges cleanly from root after the entrypoint
# fixes bind-mount ownership (common OpenShift/Docker UX
# issue).
#
# v0.4.65 dropped `nfs-common` — kernel-mount NFS is gone. The SMB
# shares replacement uses userspace `smbclient` and needs no kernel
# helpers.
#
# A future "openpxe-static" variant could drop everything except the
# binary onto distroless once we move the Windows + NFS legs to
# binary onto distroless once we move the Windows + SMB legs to
# in-process Rust crates.
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
+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
+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