Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 2. NFS AUTH_SYS credential — fixes NFS3ERR_ACCES

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

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

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

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

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

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

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

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

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

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

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

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

## 3. Disk-space card on the Dashboard

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

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

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

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

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

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

## What's new

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

## What's the same

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

## Why nfs3_client

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

## Tests

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

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

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

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

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

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

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

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

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

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

What's gone:

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

What's new:

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

Why subprocess and not a Rust crate:

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

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

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

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

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

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

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

What's new:

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-26 02:32:38 -04:00
Miles WardandClaude Opus 4.7 d729a7ae2f 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 1419309a2d 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 55f4765a20 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 a1518110ed 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 7b972dc049 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 a171331a7a 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 fe4a127422 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 2c1c80a7ca 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 ec171ede47 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 115ba779da Name update 2026-05-21 02:13:08 -04:00
Miles Ward 91848e02e3 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 4e88305101 Name update 2026-05-06 14:14:09 -04:00
Miles Ward 20c585e3ed Name update 2026-05-06 14:13:38 -04:00
503432756 e1b7154b51 docs: add Linux network-boot runbook 2026-04-30 11:35:47 -04:00
Miles Ward 40064906de Name update 2026-04-30 02:30:05 -04:00
Miles Ward 49d0b00a8a 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
86 changed files with 23328 additions and 1953 deletions
+1 -1
View File
@@ -1,5 +1,4 @@
/target /target
Cargo.lock
data/isos/*.iso data/isos/*.iso
data/isos/*.partial data/isos/*.partial
data/isos/*.meta.json data/isos/*.meta.json
@@ -11,3 +10,4 @@ data/work/
# settings.local.json is your personal allowlist history and shouldn't be). # settings.local.json is your personal allowlist history and shouldn't be).
.claude/settings.local.json .claude/settings.local.json
.claude/worktrees/ .claude/worktrees/
.claude/scheduled_tasks.lock
Generated
+4642
View File
File diff suppressed because it is too large Load Diff
+41 -13
View File
@@ -8,16 +8,16 @@ members = [
"crates/iso-store", "crates/iso-store",
"crates/ipxe-assets", "crates/ipxe-assets",
"crates/webui", "crates/webui",
"crates/pxeforge", "crates/openpxe",
] ]
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.5.2"
edition = "2021" edition = "2021"
rust-version = "1.80" rust-version = "1.95"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
repository = "https://github.com/casperadmin/PXEForge" repository = "https://gitea.milesward.dev/mward4/OpenPXE"
authors = ["PXEForge contributors"] authors = ["OpenPXE contributors"]
[workspace.dependencies] [workspace.dependencies]
tokio = { version = "1.40", features = ["full"] } tokio = { version = "1.40", features = ["full"] }
@@ -35,7 +35,7 @@ axum = { version = "0.7", features = ["macros", "multipart", "http2"] }
tower = "0.5" tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] } tower-http = { version = "0.6", features = ["fs", "trace", "cors", "limit"] }
hyper = "1.4" hyper = "1.4"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
mime = "0.3" mime = "0.3"
mime_guess = "2.0" mime_guess = "2.0"
@@ -53,17 +53,45 @@ uuid = { version = "1.10", features = ["v4", "serde"] }
time = { version = "0.3", features = ["serde", "serde-human-readable", "formatting", "macros"] } time = { version = "0.3", features = ["serde", "serde-human-readable", "formatting", "macros"] }
sha2 = "0.10" sha2 = "0.10"
hex = "0.4" hex = "0.4"
bcrypt = "0.15"
once_cell = "1.19" once_cell = "1.19"
parking_lot = "0.12" parking_lot = "0.12"
rust-embed = { version = "8.5", features = ["include-exclude"] } rust-embed = { version = "8.5", features = ["include-exclude"] }
pxeforge-core = { path = "crates/core" } # v0.4.67: pure-Rust NFSv3 client. Replaces the (deleted-in-v0.4.65)
pxeforge-dhcp-proxy = { path = "crates/dhcp-proxy" } # kernel-mount NFS path with an in-process implementation that works
pxeforge-tftp = { path = "crates/tftp" } # in any container — no kernel modules, no CAP_SYS_ADMIN, no
pxeforge-http-api = { path = "crates/http-api" } # subprocess. Ships alongside the userspace SMB consumer; operators
pxeforge-iso-store = { path = "crates/iso-store" } # pick whichever protocol their NAS prefers.
pxeforge-ipxe-assets = { path = "crates/ipxe-assets" } nfs3_client = { version = "0.9", features = ["tokio"] }
pxeforge-webui = { path = "crates/webui" } nfs3_types = "0.5"
# v0.5.0: SMTP for webhook notifications (Slack/Teams/Discord go over
# plain HTTP via reqwest; email needs a real SMTP client). rustls TLS
# to match reqwest and stay musl-static-friendly — no OpenSSL.
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "builder", "hostname"] }
# v0.5.1: pure-Rust SAML 2.0 Service Provider. bergshamra does XML-DSig
# verification + exclusive c14n with RustCrypto (no OpenSSL/xmlsec/libxml2
# C deps), so the static musl binary stays OpenSSL-free — samael was
# rejected precisely because it hard-requires OpenSSL. We build the thin
# SP layer (AuthnRequest, metadata parse, SAMLResponse semantics) on top.
bergshamra = "0.4"
roxmltree = "0.21"
quick-xml = "0.40"
x509-parser = "0.18"
# flate2 default backend is miniz_oxide (pure Rust) — do NOT enable the
# zlib/zlib-ng C backends, which would break the musl-static build.
flate2 = "1.1"
base64 = "0.22"
openpxe-core = { path = "crates/core" }
openpxe-dhcp-proxy = { path = "crates/dhcp-proxy" }
openpxe-tftp = { path = "crates/tftp" }
openpxe-http-api = { path = "crates/http-api" }
openpxe-iso-store = { path = "crates/iso-store" }
openpxe-ipxe-assets = { path = "crates/ipxe-assets" }
openpxe-webui = { path = "crates/webui" }
[workspace.lints.rust] [workspace.lints.rust]
unsafe_code = "deny" unsafe_code = "deny"
+65 -54
View File
@@ -1,14 +1,16 @@
# PXEForge # OpenPXE
Container-native PXE boot server. A Rust reimplementation of Container-native PXE boot server. A Rust reimplementation of
[iVentoy (ventoy/PXE)](https://github.com/ventoy/PXE), designed from scratch [iVentoy (ventoy/PXE)](https://github.com/ventoy/PXE), designed from scratch
for Docker/OCI and OpenShift. Upload `.iso` files via the web UI; network for Docker/OCI and OpenShift. Upload `.iso` files via the web UI; network
clients PXE-boot them. clients PXE-boot them.
> **Status:** Phase 3 MVP. Container image builds and runs, gate flow > **Status:** v0.4.1 / pre-beta. Phases 15 complete: full PXE stack,
> validated end-to-end (two clients join queue → operator assigns in UI → > Queued Deployment queue, NFS-share ISO sources, live tracing log + an
> both wake within 1 s with the correct boot script). Ready for real > operator terminal, per-MAC host bindings, Prometheus `/metrics`,
> hardware validation. > light/dark theme toggle, animated OpenPXE imaging-progress widget,
> chunked ISO uploads, and per-ISO boot passwords. The test suite and
> clippy are part of the release checklist. Ready for real-hardware validation.
## Design non-negotiables ## Design non-negotiables
@@ -36,18 +38,26 @@ clients PXE-boot them.
``` ```
Default > Boot from Local HDD Default > Boot from Local HDD
Installers > Linux Installers / Windows Installers Installers > Linux Installers / Windows Installers
Tools > Utilities / PXEForge Shell / Network Card Info Tools > Utilities / OpenPXE Shell / Network Card Info
Gated Deployment Queued Deployment
``` ```
6. **Gated Deployment queue** — the "horse race gate" flow. A client that 6. **Queued Deployment queue** — the coordinated launch flow. A client that
selects *Gated Deployment* gets a numbered position and waits. The selects *Queued Deployment* gets a numbered position and waits. The
operator picks an ISO in the web UI and fires it to every waiting operator picks an ISO in the web UI and fires it to every waiting
client simultaneously. client simultaneously.
7. **Web UI** (Netbox-style): sidebar nav (Dashboard / Clients / Gated 7. **Web UI** (Netbox-style): sidebar nav (Dashboard / Network / Queue /
Deployment / Images / Settings / About), top tabs, dark theme, teal Storage / Hosts / Terminal / About), light + dark themes
accents. All assets served from the binary — no external requests. (toggle top-right or press `T`), animated OpenPXE progress
widget when devices are imaging. All assets served from the binary —
no external requests.
8. **Per-MAC host bindings.** Pin a MAC to a boot target and the client
skips the menu, chains straight through.
9. **Prometheus metrics** at `/metrics` — DHCP replies by arch, TFTP
transfer counts and bytes, HTTP request counts by route, queue /
imaging gauges, uptime, build info. Plain text exposition format,
no external metrics framework dependency.
8. **Settings API** lets you change the default boot-menu timeout (default 8. **Settings API** lets you change the default boot-menu timeout (default
600s), the timeout action (stay / Local HDD / Gated Deployment), and 600s), the timeout action (stay / Local HDD / Queued Deployment), and
feature toggles like Windows ISO support. The iPXE scripts regenerate feature toggles like Windows ISO support. The iPXE scripts regenerate
on every request using current settings. on every request using current settings.
@@ -70,17 +80,17 @@ skip TFTP and respond with an HTTP URL.
./scripts/fetch-ipxe.sh ./scripts/fetch-ipxe.sh
# 2. Build the container image (~3 min first time). # 2. Build the container image (~3 min first time).
docker buildx build -f deploy/docker/Dockerfile -t pxeforge:0.1.0 --load . docker buildx build -f deploy/docker/Dockerfile -t openpxe:0.4.1 --load .
# 3. Run it on the box plugged into your PXE network. Set PUBLIC_IP to # 3. Run it on the box plugged into your PXE network. Set PUBLIC_IP to
# this host's LAN address so advertised iPXE URLs are reachable. # this host's LAN address so advertised iPXE URLs are reachable.
docker run -d --name pxeforge \ docker run -d --name openpxe \
--network host \ --network host \
-e PXEFORGE_PUBLIC_IP=10.0.0.5 \ -e OPENPXE_PUBLIC_IP=10.0.0.5 \
-e PXEFORGE_DHCP_MODE=proxy \ -e OPENPXE_DHCP_MODE=proxy \
-v $PWD/data/isos:/var/lib/pxeforge/isos \ -v $PWD/data/isos:/var/lib/openpxe/isos \
-v $PWD/data/work:/var/lib/pxeforge/work \ -v $PWD/data/work:/var/lib/openpxe/work \
pxeforge:0.1.0 openpxe:0.4.1
# 4. Open the UI and drop an ISO in. # 4. Open the UI and drop an ISO in.
open http://10.0.0.5 open http://10.0.0.5
@@ -88,16 +98,16 @@ open http://10.0.0.5
Host networking is required in proxy mode so the container sees DHCPDISCOVER Host networking is required in proxy mode so the container sees DHCPDISCOVER
broadcasts from the PXE VLAN. On macOS/Windows hosts Docker runs in a Linux broadcasts from the PXE VLAN. On macOS/Windows hosts Docker runs in a Linux
VM, so "host" means the VM — use `pxeforge-dev` in `docker-compose.yml` for VM, so "host" means the VM — use `openpxe-dev` in `docker-compose.yml` for
API-only testing on a laptop. API-only testing on a laptop.
### Quick start — docker compose ### Quick start — docker compose
```bash ```bash
# MVP / API testing on a laptop (no DHCP, high ports): # MVP / API testing on a laptop (no DHCP, high ports):
PXEFORGE_PUBLIC_IP=127.0.0.1 docker compose up pxeforge-dev OPENPXE_PUBLIC_IP=127.0.0.1 docker compose up openpxe-dev
# Real PXE deployment on a Linux host (host network, DHCP proxy on): # Real PXE deployment on a Linux host (host network, DHCP proxy on):
PXEFORGE_PUBLIC_IP=10.0.0.5 docker compose up pxeforge OPENPXE_PUBLIC_IP=10.0.0.5 docker compose up openpxe
``` ```
### Multi-arch build + push ### Multi-arch build + push
@@ -106,12 +116,12 @@ For deploying to x86_64 servers, build both arches in one manifest:
```bash ```bash
# One-time: bootstrap a multi-arch builder. # One-time: bootstrap a multi-arch builder.
docker buildx create --name pxeforge-multi --driver docker-container --use docker buildx create --name openpxe-multi --driver docker-container --use
# Build + push both linux/amd64 and linux/arm64 under one tag. # Build + push both linux/amd64 and linux/arm64 under one tag.
docker buildx build --builder pxeforge-multi \ docker buildx build --builder openpxe-multi \
--platform linux/amd64,linux/arm64 \ --platform linux/amd64,linux/arm64 \
-t ghcr.io/YOUR-ORG/pxeforge:0.1.0 \ -t ghcr.io/YOUR-ORG/openpxe:0.4.1 \
--push \ --push \
-f deploy/docker/Dockerfile . -f deploy/docker/Dockerfile .
``` ```
@@ -142,31 +152,31 @@ same pipeline the web UI uses (introspection + boot-entry generation):
```bash ```bash
docker run --rm \ docker run --rm \
-v /my/iso-library:/seed:ro \ -v /my/iso-library:/seed:ro \
-v pxeforge-data:/var/lib/pxeforge/isos \ -v openpxe-data:/var/lib/openpxe/isos \
-e PXEFORGE_PUBLIC_IP=10.0.0.5 \ -e OPENPXE_PUBLIC_IP=10.0.0.5 \
pxeforge:0.1.0 seed --from /seed openpxe:0.4.1 seed --from /seed
# Dry run first to see what would be imported: # Dry run first to see what would be imported:
docker run --rm -v /my/iso-library:/seed:ro pxeforge:0.1.0 seed --from /seed --dry-run docker run --rm -v /my/iso-library:/seed:ro openpxe:0.4.1 seed --from /seed --dry-run
``` ```
### Environment overrides ### Environment overrides
| Var | Default | Meaning | | Var | Default | Meaning |
|------------------------|-----------------------------|----------------------------------------| |------------------------|-----------------------------|----------------------------------------|
| `PXEFORGE_HTTP_PORT` | `80` | Web UI + boot script HTTP port | | `OPENPXE_HTTP_PORT` | `80` | Web UI + boot script HTTP port |
| `PXEFORGE_TFTP_PORT` | `69` | TFTP port | | `OPENPXE_TFTP_PORT` | `69` | TFTP port |
| `PXEFORGE_DHCP_PORT` | `67` | DHCP server-side port | | `OPENPXE_DHCP_PORT` | `67` | DHCP server-side port |
| `PXEFORGE_DHCP_MODE` | `proxy` | `proxy` or `disabled` | | `OPENPXE_DHCP_MODE` | `proxy` | `proxy` or `disabled` |
| `PXEFORGE_PUBLIC_IP` | auto-detect | Advertised IP for clients. Startup **fails** if unset and auto-detect returns loopback. | | `OPENPXE_PUBLIC_IP` | auto-detect | Advertised IP for clients. Startup **fails** if unset and auto-detect returns loopback. |
| `PXEFORGE_ISO_DIR` | `/var/lib/pxeforge/isos` | Where uploaded ISOs live | | `OPENPXE_ISO_DIR` | `/var/lib/openpxe/isos` | Where uploaded ISOs live |
| `PXEFORGE_WORK_DIR` | `/var/lib/pxeforge/work` | Scratch + runtime settings | | `OPENPXE_WORK_DIR` | `/var/lib/openpxe/work` | Scratch + runtime settings |
| `PXEFORGE_LOG` | `info,pxeforge=debug` | `tracing` filter | | `OPENPXE_LOG` | `info,openpxe=debug` | `tracing` filter |
## What the boot menu looks like on a real client ## What the boot menu looks like on a real client
``` ```
PXEForge - network boot menu OpenPXE - network boot menu
------------------------- Default ------------------------- ------------------------- Default -------------------------
Boot from Local HDD Boot from Local HDD
@@ -177,14 +187,14 @@ docker run --rm -v /my/iso-library:/seed:ro pxeforge:0.1.0 seed --from /seed --d
Tools > Utilities / Shell / Tools > Utilities / Shell /
NIC Info / Reboot / NIC Info / Reboot /
Exit and continue BIOS Exit and continue BIOS
---------------------- Gated Deployment ------------------ ---------------------- Queued Deployment ------------------
Gated Deployment (join queue) Queued Deployment (join queue)
``` ```
Linux/Windows submenus show file sizes iVentoy-style: Linux/Windows submenus show file sizes iVentoy-style:
``` ```
PXEForge - Linux Installers OpenPXE - Linux Installers
[ 4376 MB] CentOS-7-x86_64-DVD-1810 [ 4376 MB] CentOS-7-x86_64-DVD-1810
[ 2002 MB] Fedora-Workstation-Live-x86_64-38-1.6 [ 2002 MB] Fedora-Workstation-Live-x86_64-38-1.6
@@ -199,8 +209,8 @@ ISOs you upload via drag-and-drop in the web UI plus toggles in Settings.
```bash ```bash
oc apply -f deploy/openshift/ oc apply -f deploy/openshift/
oc -n pxeforge get all oc -n openpxe get all
oc -n pxeforge get route pxeforge -o jsonpath='{.spec.host}' oc -n openpxe get route openpxe -o jsonpath='{.spec.host}'
``` ```
### Why a custom SCC? ### Why a custom SCC?
@@ -208,7 +218,7 @@ oc -n pxeforge get route pxeforge -o jsonpath='{.spec.host}'
The default `restricted-v2` blocks `hostNetwork` and all capabilities. PXE The default `restricted-v2` blocks `hostNetwork` and all capabilities. PXE
cannot work without host network (CNI overlays don't deliver L2 broadcast cannot work without host network (CNI overlays don't deliver L2 broadcast
into pod netns), and we need `NET_BIND_SERVICE` to bind <1024. The custom into pod netns), and we need `NET_BIND_SERVICE` to bind <1024. The custom
`pxeforge-scc` grants exactly those two and nothing else. No raw sockets, `openpxe-scc` grants exactly those two and nothing else. No raw sockets,
no privileged mode — proxy-mode DHCP sidesteps the usual requirements. no privileged mode — proxy-mode DHCP sidesteps the usual requirements.
### What's on host ports ### What's on host ports
@@ -228,7 +238,7 @@ the node's host IP directly for UDP.
Enabled by toggling **Windows ISO support** under Settings. The flow: Enabled by toggling **Windows ISO support** under Settings. The flow:
1. Upload a stock Microsoft Windows install ISO (vanilla, no pre-processing). 1. Upload a stock Microsoft Windows install ISO (vanilla, no pre-processing).
2. On upload, PXEForge extracts the ISO and uses `wimlib-imagex` to rewrite 2. On upload, OpenPXE extracts the ISO and uses `wimlib-imagex` to rewrite
image index 2 (WinPE) of `sources/boot.wim`. It injects exactly two image index 2 (WinPE) of `sources/boot.wim`. It injects exactly two
plain-text files: plain-text files:
- `Windows/System32/winpeshl.ini` — tells WinPE to run `startnet.cmd`. - `Windows/System32/winpeshl.ini` — tells WinPE to run `startnet.cmd`.
@@ -260,22 +270,23 @@ operational constraints inherited from the design:
- Hardware with NICs/storage controllers missing from WinPE's bundled - Hardware with NICs/storage controllers missing from WinPE's bundled
drivers will need a driver-pack injection step (not yet implemented). drivers will need a driver-pack injection step (not yet implemented).
## Gated Deployment ## Queued Deployment
The "horse race gate" flow, end to end: The coordinated launch flow, end to end:
1. A client boots and picks **Gated Deployment** in the PXE menu (or falls 1. A client boots and picks **Queued Deployment** in the PXE menu (or falls
through on timeout with the default `timeout_action`). through on timeout with the default `timeout_action`).
2. The client joins the queue, gets a numbered gate position, and enters a 2. The client joins the queue, gets a numbered queue position, and enters a
long-poll loop (25s per request, auto-renewed). long-poll loop (25s per request, auto-renewed).
3. In the web UI's **Gated Deployment** tab, the operator sees each waiting 3. In the web UI's **Queued Deployment** tab, the operator sees each waiting
client with its MAC, IP, arch, and position. client with its MAC, IP, arch, and position.
4. The operator selects an image and clicks **Launch for all waiting**. 4. The operator selects an image and clicks **Launch for all waiting**.
The server broadcasts the assignment to every gated client via a The server broadcasts the assignment to every queued client via a
`tokio::sync::Notify`; each client's next poll returns the boot script `tokio::sync::Notify`; each client's next poll returns the boot script
for the chosen image. for the chosen image.
5. Every client chains the same image at effectively the same moment — the 5. Every client chains the same image at effectively the same moment. The
gate opens and the horses run together. queue stays visible until the operator releases entries, which keeps a
useful audit trail during hardware testing.
No user-facing iPXE anywhere in this flow. The client only ever runs No user-facing iPXE anywhere in this flow. The client only ever runs
scripts we generate; the operator only interacts with the web UI. scripts we generate; the operator only interacts with the web UI.
+19 -2
View File
@@ -1,10 +1,10 @@
[package] [package]
name = "pxeforge-core" name = "openpxe-core"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
description = "Shared types, config, and arch detection for PXEForge" description = "Shared types, config, and arch detection for OpenPXE"
[lints] [lints]
workspace = true workspace = true
@@ -21,6 +21,23 @@ time.workspace = true
uuid.workspace = true uuid.workspace = true
parking_lot.workspace = true parking_lot.workspace = true
tokio = { workspace = true, features = ["sync", "rt", "macros", "time"] } tokio = { workspace = true, features = ["sync", "rt", "macros", "time"] }
# bcrypt for the admin Forms auth (v0.4.5). Already in the workspace
# for per-ISO boot passwords; just re-exported here.
bcrypt.workspace = true
# v0.5.1: pure-Rust SAML 2.0 SP. bergshamra = XML-DSig verify + exclusive
# c14n (no OpenSSL/C). roxmltree/quick-xml parse + build SAML XML;
# x509-parser pulls the IdP signing cert out of metadata; flate2+base64
# encode the HTTP-Redirect binding's SAMLRequest.
bergshamra.workspace = true
roxmltree.workspace = true
quick-xml.workspace = true
x509-parser.workspace = true
flate2.workspace = true
base64.workspace = true
[dev-dependencies] [dev-dependencies]
tempfile = "3.12" tempfile = "3.12"
# v0.5.1: generate a throwaway self-signed signing cert/key so SAML
# verification tests can produce genuinely signed SAMLResponses.
rcgen = "0.13"
+5 -3
View File
@@ -49,9 +49,8 @@ impl ClientArch {
// ARM32 UEFI: upstream boot.ipxe.org does not publish a prebuilt // ARM32 UEFI: upstream boot.ipxe.org does not publish a prebuilt
// snponly variant for this arch. We return None so the DHCP // snponly variant for this arch. We return None so the DHCP
// proxy declines rather than advertising a file we can't serve. // proxy declines rather than advertising a file we can't serve.
Self::Arm32Uefi => return None, Self::Arm32Uefi | Self::Unknown(_) => return None,
Self::Arm64Uefi => "snponly-arm64.efi", Self::Arm64Uefi => "snponly-arm64.efi",
Self::Unknown(_) => return None,
}) })
} }
@@ -127,7 +126,10 @@ mod tests {
fn bootfile_names_stable() { fn bootfile_names_stable() {
assert_eq!(ClientArch::LegacyX86.ipxe_bootfile(), Some("undionly.kpxe")); assert_eq!(ClientArch::LegacyX86.ipxe_bootfile(), Some("undionly.kpxe"));
assert_eq!(ClientArch::X64Uefi.ipxe_bootfile(), Some("snponly.efi")); assert_eq!(ClientArch::X64Uefi.ipxe_bootfile(), Some("snponly.efi"));
assert_eq!(ClientArch::Arm64Uefi.ipxe_bootfile(), Some("snponly-arm64.efi")); assert_eq!(
ClientArch::Arm64Uefi.ipxe_bootfile(),
Some("snponly-arm64.efi")
);
assert_eq!(ClientArch::Unknown(0xFFFF).ipxe_bootfile(), None); assert_eq!(ClientArch::Unknown(0xFFFF).ipxe_bootfile(), None);
} }
+353
View File
@@ -0,0 +1,353 @@
//! Operator authentication — Sonarr/Radarr-style single-admin Forms model.
//!
//! On a fresh install, no admin account exists; the WebUI's first-run
//! flow prompts the operator to create one. After that the chosen
//! credentials gate `/api/*` access. The admin can rotate username +
//! password from Settings → Account.
//!
//! Multi-user RBAC isn't a goal for OpenPXE — the user explicitly asked
//! for "you have access or you don't". When SSO is configured, additional
//! users come in through the IdP; the locally-stored admin is the
//! fallback owner who can change SSO config or the seal-breaker for an
//! IdP outage. So one record is enough.
//!
//! Storage policy mirrors [`crate::host_bindings::HostBindings`] and
//! [`crate::boot_log::BootLog`]: in-memory authoritative; disk is the
//! crash-survival cache; a corrupt `auth.json` falls back to "no admin
//! configured" rather than blocking startup, which puts the UI back
//! into setup mode rather than locking the operator out.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use time::OffsetDateTime;
use crate::{Error, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdminAccount {
pub username: String,
/// bcrypt hash (cost 10). The plaintext password never leaves the
/// request that set it — same discipline as the per-ISO boot password.
pub password_hash: String,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
}
/// Public projection — no hash, safe to ship to the WebUI.
#[derive(Debug, Clone, Serialize)]
pub struct AdminPublic {
pub username: String,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
}
impl From<&AdminAccount> for AdminPublic {
fn from(a: &AdminAccount) -> Self {
Self {
username: a.username.clone(),
created_at: a.created_at,
updated_at: a.updated_at,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Inner {
admin: Option<AdminAccount>,
}
/// In-memory + on-disk admin registry. Cheap to clone.
#[derive(Debug, Clone)]
pub struct AdminStore {
path: Arc<PathBuf>,
inner: Arc<RwLock<Inner>>,
}
impl AdminStore {
/// Load from `<work_dir>/auth.json`, or start empty. A bad file
/// logs a warning and falls back to "no admin configured" — better
/// to surface the setup flow than lock the operator out of their
/// own install.
#[must_use]
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("auth.json");
let inner = match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str::<Inner>(&text) {
Ok(parsed) => parsed,
Err(e) => {
tracing::warn!(
target: "openpxe::auth",
"auth.json present but unreadable ({e}); starting in setup mode"
);
Inner::default()
}
},
Err(_) => Inner::default(),
};
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(inner)),
}
}
/// Has an admin been bootstrapped? Drives the first-run / login
/// fork in the HTTP layer.
#[must_use]
pub fn is_configured(&self) -> bool {
self.inner.read().admin.is_some()
}
/// Public-safe snapshot for the WebUI.
#[must_use]
pub fn snapshot(&self) -> Option<AdminPublic> {
self.inner.read().admin.as_ref().map(AdminPublic::from)
}
/// First-run setup: create the admin account. Fails if one already
/// exists — the HTTP layer surfaces that as 409.
pub fn bootstrap(&self, username: &str, password: &str) -> Result<AdminPublic> {
validate_username(username)?;
validate_password(password)?;
let hash = bcrypt_hash(password)?;
let now = OffsetDateTime::now_utc();
let admin = AdminAccount {
username: username.trim().to_string(),
password_hash: hash,
created_at: now,
updated_at: now,
};
{
let mut g = self.inner.write();
if g.admin.is_some() {
return Err(Error::Invalid(
"admin account already configured".into(),
));
}
g.admin = Some(admin.clone());
}
self.persist();
tracing::info!(
target: "openpxe::auth",
username = %admin.username,
"admin account created (first-run setup)"
);
Ok((&admin).into())
}
/// Verify credentials. Returns the admin record (public projection)
/// on success, `Ok(None)` on mismatch, `Err` on systemic bcrypt
/// failure (treated as "auth not available right now" by callers).
pub fn verify(&self, username: &str, password: &str) -> Result<Option<AdminPublic>> {
let Some(admin) = self.inner.read().admin.clone() else {
return Ok(None);
};
if username.trim() != admin.username {
return Ok(None);
}
// bcrypt compares in constant time relative to the same hash.
// Doing the username check first is fine — a username mismatch
// returns immediately, but the only thing leaked is "this isn't
// the admin's username" which the operator already knows.
match bcrypt::verify(password, &admin.password_hash) {
Ok(true) => Ok(Some((&admin).into())),
Ok(false) => Ok(None),
Err(e) => Err(Error::Other(e.into())),
}
}
/// Rotate username and/or password. `current_password` must match
/// the *existing* hash — same flow as Sonarr's "current password
/// required to change". `new_username`/`new_password` are optional:
/// pass only what you want to change.
pub fn update_credentials(
&self,
current_password: &str,
new_username: Option<&str>,
new_password: Option<&str>,
) -> Result<AdminPublic> {
// Re-check ownership before any state mutation.
let existing = self
.inner
.read()
.admin
.clone()
.ok_or_else(|| Error::Invalid("no admin configured".into()))?;
match bcrypt::verify(current_password, &existing.password_hash) {
Ok(true) => {}
Ok(false) => return Err(Error::Invalid("current password is incorrect".into())),
Err(e) => return Err(Error::Other(e.into())),
}
let mut updated = existing.clone();
if let Some(u) = new_username {
validate_username(u)?;
updated.username = u.trim().to_string();
}
if let Some(p) = new_password {
validate_password(p)?;
updated.password_hash = bcrypt_hash(p)?;
}
updated.updated_at = OffsetDateTime::now_utc();
{
let mut g = self.inner.write();
g.admin = Some(updated.clone());
}
self.persist();
tracing::info!(
target: "openpxe::auth",
username = %updated.username,
"admin credentials updated"
);
Ok((&updated).into())
}
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::auth", "serialize auth.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::auth", "write auth.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::warn!(target: "openpxe::auth", "rename auth.json: {e}");
}
}
}
fn validate_username(u: &str) -> Result<()> {
let u = u.trim();
if u.is_empty() {
return Err(Error::Invalid("username must not be empty".into()));
}
if u.len() > 64 {
return Err(Error::Invalid("username must be 64 chars or fewer".into()));
}
if !u.chars().all(|c| c.is_ascii_graphic() && c != ':') {
return Err(Error::Invalid(
"username must be ASCII printable with no ':' character".into(),
));
}
Ok(())
}
fn validate_password(p: &str) -> Result<()> {
if p.len() < 8 {
return Err(Error::Invalid(
"password must be at least 8 characters".into(),
));
}
if p.len() > 256 {
return Err(Error::Invalid(
"password must be 256 characters or fewer".into(),
));
}
Ok(())
}
fn bcrypt_hash(password: &str) -> Result<String> {
bcrypt::hash(password, bcrypt::DEFAULT_COST).map_err(|e| Error::Other(e.into()))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn empty_after_load_when_no_file() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
assert!(!s.is_configured());
assert!(s.snapshot().is_none());
}
#[test]
fn bootstrap_then_verify_round_trip() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
let pub_ = s.bootstrap("admin", "hunter2hunter2").unwrap();
assert_eq!(pub_.username, "admin");
assert!(s.is_configured());
// Correct creds match; wrong creds don't.
assert!(s.verify("admin", "hunter2hunter2").unwrap().is_some());
assert!(s.verify("admin", "wrong").unwrap().is_none());
assert!(s.verify("nobody", "hunter2hunter2").unwrap().is_none());
}
#[test]
fn bootstrap_rejects_second_call() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
s.bootstrap("admin", "hunter2hunter2").unwrap();
let r = s.bootstrap("other", "anotherpass1");
assert!(matches!(r, Err(Error::Invalid(_))));
}
#[test]
fn round_trip_survives_disk_reload() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
s.bootstrap("admin", "hunter2hunter2").unwrap();
drop(s);
let s2 = AdminStore::load_or_default(dir.path());
assert!(s2.is_configured());
assert!(s2.verify("admin", "hunter2hunter2").unwrap().is_some());
}
#[test]
fn update_credentials_requires_current_password() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
s.bootstrap("admin", "hunter2hunter2").unwrap();
// Wrong current password → no change.
let r = s.update_credentials("nope", None, Some("newpassword1"));
assert!(matches!(r, Err(Error::Invalid(_))));
assert!(s.verify("admin", "hunter2hunter2").unwrap().is_some());
// Correct current password rotates only what's supplied.
s.update_credentials("hunter2hunter2", Some("alice"), Some("newpassword1"))
.unwrap();
assert!(s.verify("admin", "hunter2hunter2").unwrap().is_none());
assert!(s.verify("alice", "newpassword1").unwrap().is_some());
}
#[test]
fn update_credentials_partial_password_only_keeps_username() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
s.bootstrap("admin", "hunter2hunter2").unwrap();
s.update_credentials("hunter2hunter2", None, Some("newpassword1"))
.unwrap();
assert!(s.verify("admin", "newpassword1").unwrap().is_some());
}
#[test]
fn validates_username_and_password() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
assert!(s.bootstrap("", "hunter2hunter2").is_err());
assert!(s.bootstrap("ad:min", "hunter2hunter2").is_err()); // ':' reserved
assert!(s.bootstrap("admin", "short").is_err()); // <8 chars
// 65-char username is too long.
let long = "a".repeat(65);
assert!(s.bootstrap(&long, "hunter2hunter2").is_err());
}
}
+249
View File
@@ -0,0 +1,249 @@
//! Boot-event log — "who installed what, when, from where".
//!
//! Each `/boot/<entry>.ipxe` fetch that actually goes on to serve a boot
//! script lands an entry here. The log is bounded in memory (newest-first,
//! ring-buffered at [`BootLog::CAP`]) and is mirrored append-only to
//! `<work_dir>/boot_log.jsonl`. Mirrors `HostBindings`'s "in-memory is
//! authoritative, disk is a cache" policy — a corrupt log file should
//! never block PXE for the network.
//!
//! We deliberately don't push these onto the `LogBus` (the operator
//! terminal stream). The terminal already shows the http traces; the
//! Host log is a curated, persistent, easy-to-scan view of "what got
//! imaged on what hardware" and conflating the two would be noisy.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::io::Write;
use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::Arc;
use time::OffsetDateTime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootEvent {
#[serde(with = "time::serde::rfc3339")]
pub timestamp: OffsetDateTime,
/// Lowercase, colon-separated. `None` when iPXE didn't supply
/// `?mac=${mac}` in the chain URL (older bookmarks, custom scripts).
pub mac: Option<String>,
/// Connecting peer's IP — taken from the TCP socket when available
/// (PXE clients connect direct, no reverse proxy), and falls back to
/// `X-Forwarded-For` for the rare case where one is present.
pub ip: Option<IpAddr>,
/// `BootEntry::id` — the same id used in `/boot/<id>.ipxe`.
pub target_id: String,
/// Human-friendly label: the ISO's filename / volume label / entry
/// title. Pre-resolved at log time so the UI can render without
/// joining against the ISO store (and so "what image was installed?"
/// survives the operator deleting the ISO later).
pub target_title: String,
}
/// In-memory ring + disk-backed append log of boot events. Cheap to
/// clone; the inner state is `Arc<RwLock<_>>`.
#[derive(Debug, Clone)]
pub struct BootLog {
path: Arc<PathBuf>,
inner: Arc<RwLock<VecDeque<BootEvent>>>,
}
impl BootLog {
/// Newest entries we retain in memory. Past this, the oldest gets
/// evicted — the on-disk JSONL keeps the full history for offline
/// inspection. 500 covers a typical install-day's worth without
/// turning the Hosts tab into a wall of text.
pub const CAP: usize = 500;
/// Load up to `CAP` newest events from `<work_dir>/boot_log.jsonl`,
/// or start empty if the file is missing / unreadable.
#[must_use]
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("boot_log.jsonl");
let mut events = VecDeque::with_capacity(Self::CAP);
if let Ok(text) = std::fs::read_to_string(&path) {
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<BootEvent>(line) {
Ok(ev) => {
if events.len() == Self::CAP {
events.pop_front();
}
events.push_back(ev);
}
Err(e) => {
tracing::warn!(
target: "openpxe::boot_log",
"skipping unparseable boot_log line: {e}"
);
}
}
}
}
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(events)),
}
}
/// Append an event. Persistence is best-effort and never blocks the
/// caller on a failed write (the in-memory copy is the source of
/// truth for the live UI; the JSONL is just for crash survival).
pub fn record(&self, ev: &BootEvent) {
// Push into the ring first so a slow / failing disk doesn't lose
// events for the live UI.
{
let mut g = self.inner.write();
if g.len() == Self::CAP {
g.pop_front();
}
g.push_back(ev.clone());
}
tracing::info!(
target: "openpxe::boot_log",
mac = ev.mac.as_deref().unwrap_or("?"),
ip = ev.ip.map(|i| i.to_string()).as_deref().unwrap_or("?"),
target = %ev.target_id,
"boot event"
);
// Append to disk. We tolerate write failures — they'd show up as
// missing entries on the next restart only.
let mut line = match serde_json::to_string(ev) {
Ok(s) => s,
Err(e) => {
tracing::warn!(target: "openpxe::boot_log", "serialize boot event: {e}");
return;
}
};
line.push('\n');
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(self.path.as_path())
{
Ok(mut f) => {
if let Err(e) = f.write_all(line.as_bytes()) {
tracing::warn!(target: "openpxe::boot_log", "append boot_log.jsonl: {e}");
}
}
Err(e) => {
tracing::warn!(target: "openpxe::boot_log", "open boot_log.jsonl: {e}");
}
}
}
/// Newest-first snapshot, up to `CAP` entries.
#[must_use]
pub fn list(&self) -> Vec<BootEvent> {
let g = self.inner.read();
// VecDeque preserves insertion order; reverse so newest is first.
g.iter().rev().cloned().collect()
}
#[must_use]
pub fn len(&self) -> usize {
self.inner.read().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Wipe in-memory + the on-disk file. Used by the `terminal clear`
/// equivalent or future operator action; not currently wired to a UI
/// button but exposed for completeness.
pub fn clear(&self) {
self.inner.write().clear();
let _ = std::fs::remove_file(self.path.as_path());
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn ev(target: &str, mac: Option<&str>) -> BootEvent {
BootEvent {
timestamp: OffsetDateTime::now_utc(),
mac: mac.map(str::to_string),
ip: Some("10.0.0.42".parse().unwrap()),
target_id: target.into(),
target_title: format!("{target}.iso"),
}
}
#[test]
fn record_then_list_is_newest_first() {
let dir = tempdir().unwrap();
let log = BootLog::load_or_default(dir.path());
assert!(log.is_empty());
log.record(&ev("alpha", Some("aa:bb:cc:00:00:01")));
log.record(&ev("beta", Some("aa:bb:cc:00:00:02")));
let list = log.list();
assert_eq!(list.len(), 2);
assert_eq!(list[0].target_id, "beta");
assert_eq!(list[1].target_id, "alpha");
}
#[test]
fn round_trip_through_disk() {
let dir = tempdir().unwrap();
let log = BootLog::load_or_default(dir.path());
log.record(&ev("alpha", Some("aa:bb:cc:00:00:01")));
log.record(&ev("beta", None));
drop(log);
let log2 = BootLog::load_or_default(dir.path());
assert_eq!(log2.len(), 2);
let list = log2.list();
assert_eq!(list[0].target_id, "beta");
assert_eq!(list[1].target_id, "alpha");
assert!(list[0].mac.is_none());
assert_eq!(list[1].mac.as_deref(), Some("aa:bb:cc:00:00:01"));
}
#[test]
fn ring_evicts_oldest_past_cap() {
let dir = tempdir().unwrap();
let log = BootLog::load_or_default(dir.path());
for i in 0..(BootLog::CAP + 5) {
log.record(&ev(&format!("e{i}"), None));
}
assert_eq!(log.len(), BootLog::CAP);
let list = log.list();
// Newest first; the most recent push is the last index inserted.
assert_eq!(list[0].target_id, format!("e{}", BootLog::CAP + 4));
// Oldest in-memory should be the 6th push (0..5 were evicted).
assert_eq!(list[BootLog::CAP - 1].target_id, "e5");
}
#[test]
fn clear_wipes_memory_and_disk() {
let dir = tempdir().unwrap();
let log = BootLog::load_or_default(dir.path());
log.record(&ev("alpha", None));
log.clear();
assert!(log.is_empty());
let log2 = BootLog::load_or_default(dir.path());
assert!(log2.is_empty());
}
#[test]
fn corrupt_disk_lines_are_skipped_not_fatal() {
// Write a file with one valid + one garbage line; loader should
// surface the valid one and skip the garbage.
let dir = tempdir().unwrap();
let path = dir.path().join("boot_log.jsonl");
let valid = serde_json::to_string(&ev("ok", Some("aa:bb:cc:00:00:09"))).unwrap();
std::fs::write(&path, format!("{valid}\nNOT_JSON\n{valid}\n")).unwrap();
let log = BootLog::load_or_default(dir.path());
assert_eq!(log.len(), 2);
}
}
+665
View File
@@ -0,0 +1,665 @@
//! Operator-controlled branding overrides.
//!
//! v0.5.2 splits the single brand mark into **three independent slots**,
//! FleetDM-style:
//!
//! * `light` — shown in the WebUI top-left and on the form-login page
//! when the active theme is light.
//! * `dark` — same surfaces, when the active theme is dark.
//! * `client` — the raster painted above the iPXE boot menu entries
//! (`/branding/pxe-logo`), i.e. what a PXE client sees on the screen.
//!
//! Each slot lives at `<work_dir>/branding/logo-<slot>.<ext>` and is
//! served in preference to the bundled rainbow-horizon mark when present.
//! Borrowed-from-FleetDM: tenant chrome, same product.
//!
//! Legacy continuity: a pre-v0.5.2 single `logo.<ext>` (recorded under
//! the old `logo_filename`/`logo_mime` keys) is migrated on first load
//! into both the `dark` and `client` slots — that preserves the previous
//! behaviour (one mark fed both the dark WebUI and the PXE screen) until
//! the operator uploads dedicated variants.
//!
//! Storage policy mirrors `HostBindings` / `BootLog`: in-memory cache is
//! authoritative for the current process, disk is the source of truth on
//! restart, and a corrupt cache file falls back to the bundled default
//! rather than blocking startup.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Allowed MIME types for an uploaded logo. We deliberately keep this
/// narrow — anything that can be `<img src="...">`'d into the brand
/// block, no scripts. SVG carries the obvious XSS risk for raw inline
/// HTML; we always serve the bytes as a separate asset with a strict
/// content-type rather than inlining, so SVG is safe.
pub const ALLOWED_LOGO_MIMES: &[&str] = &[
"image/svg+xml",
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
];
/// Disk cap for an uploaded logo. PXE WebUIs are operator-facing — even
/// a generous 2 MB cap is comfortable for any reasonable brand mark and
/// puts a clear bound on memory + serialization cost.
pub const MAX_LOGO_BYTES: usize = 2 * 1024 * 1024;
/// Which branded surface a logo upload targets.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogoSlot {
/// WebUI + form-login page, light theme.
Light,
/// WebUI + form-login page, dark theme.
Dark,
/// iPXE boot-menu background seen by PXE clients.
Client,
}
impl LogoSlot {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
LogoSlot::Light => "light",
LogoSlot::Dark => "dark",
LogoSlot::Client => "client",
}
}
/// Parse a slot name from the URL path segment. Case-insensitive.
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"light" => Some(LogoSlot::Light),
"dark" => Some(LogoSlot::Dark),
"client" => Some(LogoSlot::Client),
_ => None,
}
}
}
/// One brand-mark slot: a filename (relative to the branding dir) plus
/// the MIME we cached at upload time so the HTTP layer can set the
/// Content-Type without re-sniffing.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Slot {
#[serde(default, skip_serializing_if = "Option::is_none")]
filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
mime: Option<String>,
}
impl Slot {
fn clear_file(&mut self, dir: &Path) {
if let Some(name) = self.filename.take() {
let _ = std::fs::remove_file(dir.join(name));
}
self.mime = None;
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Inner {
#[serde(default)]
light: Slot,
#[serde(default)]
dark: Slot,
#[serde(default)]
client: Slot,
/// Monotonic counter bumped on every set/clear (any slot). Surfaces
/// as a cache-bust token (`/assets/logo.svg?r=<rev>`) so the browser
/// fetches the new bytes the moment the operator swaps a logo — the
/// app version alone can't do this since it doesn't change on upload.
/// Persisted so the token stays stable across restarts and keeps
/// climbing across multiple swaps.
#[serde(default)]
rev: u64,
// ── Legacy (pre-v0.5.2) single-logo keys ──────────────────────────
// Read on load for one-way migration into `dark` + `client`, then
// dropped from the persisted form (skip_serializing_if).
#[serde(default, skip_serializing_if = "Option::is_none")]
logo_filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
logo_mime: Option<String>,
}
impl Inner {
fn slot(&self, slot: LogoSlot) -> &Slot {
match slot {
LogoSlot::Light => &self.light,
LogoSlot::Dark => &self.dark,
LogoSlot::Client => &self.client,
}
}
fn slot_mut(&mut self, slot: LogoSlot) -> &mut Slot {
match slot {
LogoSlot::Light => &mut self.light,
LogoSlot::Dark => &mut self.dark,
LogoSlot::Client => &mut self.client,
}
}
}
/// In-memory + on-disk override registry. Cheap to clone; locks are
/// brief. The `branding.json` cache lives alongside the active assets
/// inside `<work_dir>/branding/`.
#[derive(Debug, Clone)]
pub struct BrandingStore {
/// Root directory: `<work_dir>/branding/`. Created on first write.
dir: Arc<PathBuf>,
inner: Arc<RwLock<Inner>>,
}
impl BrandingStore {
/// Load (or initialise empty) from `<work_dir>/branding/`. Tolerates
/// missing directories, partial state, and corrupt JSON — a bad
/// cache should never block PXE for the network. Migrates a legacy
/// single-logo file into the dark + client slots.
#[must_use]
pub fn load_or_default(work_dir: &Path) -> Self {
let dir = work_dir.join("branding");
let path = dir.join("branding.json");
let mut inner = Inner::default();
if let Ok(text) = std::fs::read_to_string(&path) {
match serde_json::from_str::<Inner>(&text) {
Ok(parsed) => inner = parsed,
Err(e) => {
tracing::warn!(
target: "openpxe::branding",
"branding.json present but unreadable ({e}); starting empty"
);
}
}
}
let store = Self {
dir: Arc::new(dir),
inner: Arc::new(RwLock::new(inner)),
};
store.migrate_legacy();
store.prune_missing();
store
}
/// One-way migration: a pre-v0.5.2 `logo.<ext>` becomes the dark +
/// client slots (the old single mark fed both the dark WebUI and the
/// PXE screen). Best-effort; failures leave the legacy file in place
/// rather than blocking startup.
fn migrate_legacy(&self) {
let (legacy_name, legacy_mime) = {
let g = self.inner.read();
(g.logo_filename.clone(), g.logo_mime.clone())
};
let Some(name) = legacy_name else { return };
let src = self.dir.join(&name);
if !src.is_file() {
// Legacy pointer is stale — just drop it.
let mut g = self.inner.write();
g.logo_filename = None;
g.logo_mime = None;
drop(g);
self.persist();
return;
}
let mime = legacy_mime.unwrap_or_else(|| "image/svg+xml".to_string());
let ext = ext_for_mime(&mime).unwrap_or("bin");
if let Ok(bytes) = std::fs::read(&src) {
// Seed dark + client only when those slots are still empty so
// a re-run (or a manual edit) never clobbers operator intent.
let needs_dark = self.inner.read().dark.filename.is_none();
let needs_client = self.inner.read().client.filename.is_none();
if needs_dark {
let _ = self.write_slot(LogoSlot::Dark, &mime, ext, &bytes);
}
if needs_client {
let _ = self.write_slot(LogoSlot::Client, &mime, ext, &bytes);
}
}
let _ = std::fs::remove_file(&src);
{
let mut g = self.inner.write();
g.logo_filename = None;
g.logo_mime = None;
}
self.persist();
tracing::info!(
target: "openpxe::branding",
"migrated legacy single logo into dark + client slots"
);
}
/// Drop in-memory slot pointers whose backing file vanished from disk
/// so the HTTP layer falls back to the bundled mark instead of 500ing.
fn prune_missing(&self) {
let mut changed = false;
{
let mut g = self.inner.write();
for slot in [LogoSlot::Light, LogoSlot::Dark, LogoSlot::Client] {
let present = g
.slot(slot)
.filename
.as_deref()
.is_some_and(|n| self.dir.join(n).is_file());
if !present && g.slot(slot).filename.is_some() {
g.slot_mut(slot).filename = None;
g.slot_mut(slot).mime = None;
changed = true;
}
}
}
if changed {
self.persist();
}
}
/// Absolute path to the logo for `slot`, if set and present on disk.
#[must_use]
pub fn slot_path(&self, slot: LogoSlot) -> Option<PathBuf> {
let g = self.inner.read();
g.slot(slot).filename.as_deref().map(|n| self.dir.join(n))
}
/// MIME of the logo for `slot`, if any.
#[must_use]
pub fn slot_mime(&self, slot: LogoSlot) -> Option<String> {
self.inner.read().slot(slot).mime.clone()
}
/// Resolve the WebUI logo for a theme, with fallback: light falls
/// back to dark and vice-versa, so a single uploaded variant still
/// shows on both themes. Returns `(path, mime)` or `None` (→ bundled).
#[must_use]
pub fn web_logo(&self, theme_is_light: bool) -> Option<(PathBuf, String)> {
let (primary, secondary) = if theme_is_light {
(LogoSlot::Light, LogoSlot::Dark)
} else {
(LogoSlot::Dark, LogoSlot::Light)
};
let g = self.inner.read();
let chosen = if g.slot(primary).filename.is_some() {
primary
} else {
secondary
};
let s = g.slot(chosen);
s.filename.as_deref().map(|n| {
(
self.dir.join(n),
s.mime
.clone()
.unwrap_or_else(|| "image/svg+xml".to_string()),
)
})
}
/// Resolve the PXE client logo (no theme fallback — the PXE screen
/// has a single mark). Returns `(path, mime)` or `None` (→ default
/// composed background).
#[must_use]
pub fn client_logo(&self) -> Option<(PathBuf, String)> {
let g = self.inner.read();
let s = &g.client;
s.filename.as_deref().map(|n| {
(
self.dir.join(n),
s.mime
.clone()
.unwrap_or_else(|| "application/octet-stream".to_string()),
)
})
}
/// Replace the logo for `slot`. Returns the chosen on-disk filename so
/// the caller can echo it back in the API response.
pub fn set_logo(
&self,
slot: LogoSlot,
mime: &str,
ext: &str,
bytes: &[u8],
) -> std::io::Result<String> {
let filename = self.write_slot(slot, mime, ext, bytes)?;
self.persist();
tracing::info!(
target: "openpxe::branding",
slot = slot.as_str(), file = %filename, mime = %mime, size = bytes.len(),
"custom logo installed"
);
Ok(filename)
}
/// Write the bytes for a slot and update the in-memory pointer + rev,
/// without persisting (the caller decides when to flush). Cleans up
/// any sibling `logo-<slot>.*` so there's exactly one file per slot.
fn write_slot(
&self,
slot: LogoSlot,
mime: &str,
ext: &str,
bytes: &[u8],
) -> std::io::Result<String> {
std::fs::create_dir_all(self.dir.as_path())?;
let safe_ext = sanitize_ext(ext);
let stem = format!("logo-{}", slot.as_str());
let filename = format!("{stem}.{safe_ext}");
let final_path = self.dir.join(&filename);
// Atomic write: tmp -> rename.
let tmp = final_path.with_extension(format!("{safe_ext}.tmp"));
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, &final_path)?;
// Clean up any sibling `logo-<slot>.<otherext>`.
if let Ok(entries) = std::fs::read_dir(self.dir.as_path()) {
for e in entries.flatten() {
let p = e.path();
let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
if name.starts_with(&format!("{stem}.")) && name != filename {
let _ = std::fs::remove_file(&p);
}
}
}
let mut g = self.inner.write();
let s = g.slot_mut(slot);
s.filename = Some(filename.clone());
s.mime = Some(mime.to_string());
g.rev = g.rev.wrapping_add(1);
Ok(filename)
}
/// Drop the override for `slot` and return to the bundled / default.
pub fn clear_logo(&self, slot: LogoSlot) -> std::io::Result<()> {
{
let mut g = self.inner.write();
let dir = self.dir.as_path();
g.slot_mut(slot).clear_file(dir);
g.rev = g.rev.wrapping_add(1);
}
self.persist();
tracing::info!(target: "openpxe::branding", slot = slot.as_str(), "custom logo cleared");
Ok(())
}
/// True if a custom logo is configured for `slot`.
#[must_use]
pub fn has_logo(&self, slot: LogoSlot) -> bool {
self.inner.read().slot(slot).filename.is_some()
}
/// True if either WebUI theme slot has a custom logo — drives the
/// FleetDM-style full-width brand block (and the `has-custom-logo`
/// class) on the sidebar + login page.
#[must_use]
pub fn has_any_web_logo(&self) -> bool {
let g = self.inner.read();
g.light.filename.is_some() || g.dark.filename.is_some()
}
/// Presence triple `(light, dark, client)` for the `/api/me` and
/// `/api/status` bootstrap payloads.
#[must_use]
pub fn presence(&self) -> (bool, bool, bool) {
let g = self.inner.read();
(
g.light.filename.is_some(),
g.dark.filename.is_some(),
g.client.filename.is_some(),
)
}
/// Cache-bust token for the logo asset URLs. Changes on every
/// set/clear so `/assets/logo.svg?r=<rev>` resolves to a fresh URL
/// whenever the operator swaps a brand mark. Stable otherwise.
#[must_use]
pub fn logo_rev(&self) -> u64 {
self.inner.read().rev
}
fn persist(&self) {
let snap = self.inner.read().clone();
let body = match serde_json::to_vec_pretty(&snap) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::branding", "serialize branding.json: {e}");
return;
}
};
if let Err(e) = std::fs::create_dir_all(self.dir.as_path()) {
tracing::warn!(target: "openpxe::branding", "mkdir branding/: {e}");
return;
}
let path = self.dir.join("branding.json");
let tmp = path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::branding", "write branding.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, &path) {
tracing::warn!(target: "openpxe::branding", "rename branding.json: {e}");
}
}
}
/// Trim arbitrary operator-supplied extension strings to a small, safe
/// alphanumeric form. Anything weird collapses to `bin`. We never let
/// the extension affect the path beyond the final segment of `logo.<x>`.
fn sanitize_ext(ext: &str) -> String {
let lc: String = ext
.chars()
.filter(char::is_ascii_alphanumeric)
.map(|c| c.to_ascii_lowercase())
.collect();
if lc.is_empty() || lc.len() > 5 {
"bin".into()
} else {
lc
}
}
/// Pick a safe filesystem extension from a MIME type. Returns `None`
/// if the MIME isn't on the [`ALLOWED_LOGO_MIMES`] allowlist.
#[must_use]
pub fn ext_for_mime(mime: &str) -> Option<&'static str> {
match mime {
"image/svg+xml" => Some("svg"),
"image/png" => Some("png"),
"image/jpeg" => Some("jpg"),
"image/webp" => Some("webp"),
"image/gif" => Some("gif"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn empty_after_load_when_no_branding_dir() {
let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path());
assert!(!b.has_logo(LogoSlot::Light));
assert!(!b.has_logo(LogoSlot::Dark));
assert!(!b.has_logo(LogoSlot::Client));
assert!(b.web_logo(false).is_none());
assert!(b.client_logo().is_none());
assert!(!b.has_any_web_logo());
}
#[test]
fn set_clear_round_trip_persists() {
let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path());
let name = b
.set_logo(LogoSlot::Dark, "image/png", "png", b"\x89PNG\r\n\x1a\nfake")
.unwrap();
assert_eq!(name, "logo-dark.png");
assert!(b.has_logo(LogoSlot::Dark));
assert_eq!(b.slot_mime(LogoSlot::Dark).as_deref(), Some("image/png"));
let (p, _) = b.web_logo(false).unwrap();
assert!(p.is_file());
// Re-open and confirm the override survives a restart.
drop(b);
let b2 = BrandingStore::load_or_default(dir.path());
assert!(b2.has_logo(LogoSlot::Dark));
assert_eq!(b2.slot_mime(LogoSlot::Dark).as_deref(), Some("image/png"));
// Clear; the file goes away and has_logo flips off.
b2.clear_logo(LogoSlot::Dark).unwrap();
assert!(!b2.has_logo(LogoSlot::Dark));
assert!(!p.exists());
}
#[test]
fn web_logo_falls_back_across_themes() {
let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path());
// Only dark uploaded — light theme falls back to it.
b.set_logo(LogoSlot::Dark, "image/png", "png", b"dark")
.unwrap();
let (p_light, _) = b.web_logo(true).expect("light falls back to dark");
assert!(p_light.ends_with("logo-dark.png"));
// Upload a distinct light — now light theme uses its own.
b.set_logo(LogoSlot::Light, "image/png", "png", b"light")
.unwrap();
let (p_light2, _) = b.web_logo(true).unwrap();
assert!(p_light2.ends_with("logo-light.png"));
// Client is independent and still unset.
assert!(b.client_logo().is_none());
}
#[test]
fn replacing_slot_removes_old_extension_sibling() {
let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path());
b.set_logo(
LogoSlot::Client,
"image/png",
"png",
b"\x89PNG\r\n\x1a\nfake",
)
.unwrap();
b.set_logo(
LogoSlot::Client,
"image/svg+xml",
"svg",
br#"<svg xmlns="http://www.w3.org/2000/svg"/>"#,
)
.unwrap();
let entries: Vec<_> = std::fs::read_dir(dir.path().join("branding"))
.unwrap()
.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned()))
.collect();
assert!(
entries.iter().any(|n| n == "logo-client.svg"),
"got {entries:?}"
);
assert!(
!entries.iter().any(|n| n == "logo-client.png"),
"stale PNG left over: {entries:?}"
);
}
#[test]
fn logo_rev_bumps_on_each_set_and_clear() {
let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path());
assert_eq!(b.logo_rev(), 0);
b.set_logo(LogoSlot::Light, "image/png", "png", b"a")
.unwrap();
assert_eq!(b.logo_rev(), 1);
b.set_logo(LogoSlot::Dark, "image/png", "png", b"b")
.unwrap();
assert_eq!(b.logo_rev(), 2);
b.clear_logo(LogoSlot::Light).unwrap();
assert_eq!(b.logo_rev(), 3);
drop(b);
let b2 = BrandingStore::load_or_default(dir.path());
assert_eq!(b2.logo_rev(), 3);
}
#[test]
fn legacy_single_logo_migrates_to_dark_and_client() {
// A pre-v0.5.2 branding.json + logo.png migrates on load.
let dir = tempdir().unwrap();
let brand_dir = dir.path().join("branding");
std::fs::create_dir_all(&brand_dir).unwrap();
std::fs::write(brand_dir.join("logo.png"), b"\x89PNG\r\n\x1a\nlegacy").unwrap();
// Hand-write the old shape (logo_filename/logo_mime, no slots).
std::fs::write(
brand_dir.join("branding.json"),
br#"{"logo_filename":"logo.png","logo_mime":"image/png","rev":4}"#,
)
.unwrap();
let b = BrandingStore::load_or_default(dir.path());
assert!(b.has_logo(LogoSlot::Dark), "dark seeded from legacy");
assert!(b.has_logo(LogoSlot::Client), "client seeded from legacy");
assert!(!b.has_logo(LogoSlot::Light), "light stays empty");
// The old logo.png is gone; per-slot files exist.
assert!(!brand_dir.join("logo.png").exists());
assert!(brand_dir.join("logo-dark.png").is_file());
assert!(brand_dir.join("logo-client.png").is_file());
// rev carried over from the legacy file and advanced as the two
// slots were seeded (each write bumps it), so it never regresses.
let migrated_rev = b.logo_rev();
assert!(
migrated_rev >= 4,
"rev should not regress below legacy: {migrated_rev}"
);
// And the migration is sticky across a restart (no re-migrate, no
// further rev churn).
drop(b);
let b2 = BrandingStore::load_or_default(dir.path());
assert!(b2.has_logo(LogoSlot::Dark));
assert!(b2.has_logo(LogoSlot::Client));
assert!(!b2.has_logo(LogoSlot::Light));
assert_eq!(b2.logo_rev(), migrated_rev, "restart must not re-migrate");
}
#[test]
fn sanitize_ext_strips_separators_and_path_chars() {
assert_eq!(sanitize_ext("svg"), "svg");
assert_eq!(sanitize_ext("../etc/passwd"), "bin");
assert_eq!(sanitize_ext("../svg"), "svg");
assert_eq!(sanitize_ext(""), "bin");
assert_eq!(sanitize_ext("PNG"), "png");
assert_eq!(sanitize_ext("svgvvvv"), "bin");
}
#[test]
fn missing_file_referenced_by_json_resolves_to_empty() {
let dir = tempdir().unwrap();
let brand_dir = dir.path().join("branding");
std::fs::create_dir_all(&brand_dir).unwrap();
// branding.json claims a dark slot whose file doesn't exist.
std::fs::write(
brand_dir.join("branding.json"),
br#"{"dark":{"filename":"logo-dark.png","mime":"image/png"},"rev":1}"#,
)
.unwrap();
let b = BrandingStore::load_or_default(dir.path());
assert!(
!b.has_logo(LogoSlot::Dark),
"should fall back when referenced file is missing"
);
}
#[test]
fn slot_parse_round_trips() {
assert_eq!(LogoSlot::parse("light"), Some(LogoSlot::Light));
assert_eq!(LogoSlot::parse("DARK"), Some(LogoSlot::Dark));
assert_eq!(LogoSlot::parse(" client "), Some(LogoSlot::Client));
assert_eq!(LogoSlot::parse("nope"), None);
assert_eq!(LogoSlot::Light.as_str(), "light");
}
#[test]
fn ext_for_mime_only_accepts_known_types() {
assert_eq!(ext_for_mime("image/png"), Some("png"));
assert_eq!(ext_for_mime("image/svg+xml"), Some("svg"));
assert_eq!(ext_for_mime("application/octet-stream"), None);
assert_eq!(ext_for_mime("text/html"), None);
}
}
+11 -4
View File
@@ -56,7 +56,9 @@ impl ClientRegistry {
) { ) {
let mut guard = self.inner.write(); let mut guard = self.inner.write();
let now = OffsetDateTime::now_utc(); let now = OffsetDateTime::now_utc();
let entry = guard.entry(mac.to_string()).or_insert_with(|| ClientSnapshot { let entry = guard
.entry(mac.to_string())
.or_insert_with(|| ClientSnapshot {
mac: mac.to_string(), mac: mac.to_string(),
last_ip: ip, last_ip: ip,
arch, arch,
@@ -67,8 +69,12 @@ impl ClientRegistry {
selected_target: None, selected_target: None,
}); });
entry.last_seen = now; entry.last_seen = now;
if ip.is_some() { entry.last_ip = ip; } if ip.is_some() {
if arch.is_some() { entry.arch = arch; } entry.last_ip = ip;
}
if arch.is_some() {
entry.arch = arch;
}
entry.events.push((now, event)); entry.events.push((now, event));
// Cap event history per client to keep memory bounded. // Cap event history per client to keep memory bounded.
const MAX_EVENTS: usize = 64; const MAX_EVENTS: usize = 64;
@@ -89,7 +95,8 @@ impl ClientRegistry {
pub fn list(&self) -> Vec<ClientSnapshot> { pub fn list(&self) -> Vec<ClientSnapshot> {
let guard = self.inner.read(); let guard = self.inner.read();
let mut v: Vec<_> = guard.values().cloned().collect(); let mut v: Vec<_> = guard.values().cloned().collect();
v.sort_by(|a, b| b.last_seen.cmp(&a.last_seen)); // Reverse-chronological by last-seen (most recent first).
v.sort_by_key(|c| std::cmp::Reverse(c.last_seen));
v v
} }
+39 -31
View File
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr}; use std::net::{IpAddr, Ipv4Addr};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct Config { pub struct Config {
pub server: ServerConfig, pub server: ServerConfig,
@@ -54,7 +54,7 @@ pub enum DhcpMode {
#[default] #[default]
Proxy, Proxy,
/// Disabled — rely on an external DHCP server that has been manually /// Disabled — rely on an external DHCP server that has been manually
/// configured with `next-server` / `filename`. PXEForge only serves TFTP /// configured with `next-server` / `filename`. OpenPXE only serves TFTP
/// + HTTP in this mode. Useful for home routers that can be pre-set. /// + HTTP in this mode. Useful for home routers that can be pre-set.
Disabled, Disabled,
} }
@@ -68,12 +68,17 @@ pub struct Paths {
pub work_dir: PathBuf, pub work_dir: PathBuf,
/// Directory containing bundled iPXE binaries (undionly.kpxe, snponly.efi, ...). /// Directory containing bundled iPXE binaries (undionly.kpxe, snponly.efi, ...).
pub ipxe_dir: PathBuf, pub ipxe_dir: PathBuf,
/// Path to the wimboot binary for Windows ISOs (optional — feature-gated). /// Path to the wimboot binary for Windows ISOs (optional — feature-controlled).
pub wimboot_path: Option<PathBuf>, pub wimboot_path: Option<PathBuf>,
/// Directory under which Windows ISOs are extracted and served via SMB. /// Directory under which Windows ISOs are extracted and served via SMB.
/// Only used when `settings.windows_enabled = true`. Defaults to /// Only used when `settings.windows_enabled = true`. Defaults to
/// `/var/lib/pxeforge/smb` in the container image. /// `/var/lib/openpxe/smb` in the container image.
pub smb_dir: PathBuf, pub smb_dir: PathBuf,
/// v0.5.2: directory holding uploaded unattended-install answer files
/// (Kickstart / Preseed / Autoinstall / Windows answer files). Kept
/// separate from `iso_dir` so answer files never appear in the ISO
/// listing or the PXE menu. Defaults to `/var/lib/openpxe/unattended`.
pub unattended_dir: PathBuf,
} }
impl Default for ServerConfig { impl Default for ServerConfig {
@@ -104,24 +109,19 @@ impl Default for NetworkConfig {
impl Default for Paths { impl Default for Paths {
fn default() -> Self { fn default() -> Self {
Self { Self {
iso_dir: PathBuf::from("/var/lib/pxeforge/isos"), iso_dir: PathBuf::from("/var/lib/openpxe/isos"),
work_dir: PathBuf::from("/var/lib/pxeforge/work"), work_dir: PathBuf::from("/var/lib/openpxe/work"),
ipxe_dir: PathBuf::from("/usr/share/pxeforge/ipxe"), ipxe_dir: PathBuf::from("/usr/share/openpxe/ipxe"),
wimboot_path: None, wimboot_path: None,
smb_dir: PathBuf::from("/var/lib/pxeforge/smb"), smb_dir: PathBuf::from("/var/lib/openpxe/smb"),
unattended_dir: PathBuf::from("/var/lib/openpxe/unattended"),
} }
} }
} }
impl Default for Config { // `Config` derives `Default` because each component supplies its own
fn default() -> Self { // non-trivial defaults via `impl Default` blocks above; deriving keeps
Self { // this in sync if a new section is added.
server: ServerConfig::default(),
network: NetworkConfig::default(),
paths: Paths::default(),
}
}
}
impl Config { impl Config {
pub fn from_toml_file(path: &Path) -> crate::Result<Self> { pub fn from_toml_file(path: &Path) -> crate::Result<Self> {
@@ -130,38 +130,46 @@ impl Config {
} }
/// Apply environment variable overrides. Env var names follow the pattern /// Apply environment variable overrides. Env var names follow the pattern
/// `PXEFORGE_<SECTION>_<FIELD>`, uppercase. Unknown vars are ignored. /// `OPENPXE_<SECTION>_<FIELD>`, uppercase. Unknown vars are ignored.
/// Call this after loading the TOML file so env takes precedence. /// Call this after loading the TOML file so env takes precedence.
pub fn apply_env(&mut self) { pub fn apply_env(&mut self) {
if let Ok(v) = std::env::var("PXEFORGE_HTTP_PORT") { if let Ok(v) = std::env::var("OPENPXE_HTTP_PORT") {
if let Ok(p) = v.parse() { self.server.http_port = p; } if let Ok(p) = v.parse() {
self.server.http_port = p;
} }
if let Ok(v) = std::env::var("PXEFORGE_TFTP_PORT") {
if let Ok(p) = v.parse() { self.server.tftp_port = p; }
} }
if let Ok(v) = std::env::var("PXEFORGE_DHCP_PORT") { if let Ok(v) = std::env::var("OPENPXE_TFTP_PORT") {
if let Ok(p) = v.parse() { self.network.dhcp_port = p; } if let Ok(p) = v.parse() {
self.server.tftp_port = p;
} }
if let Ok(v) = std::env::var("PXEFORGE_PUBLIC_IP") {
if let Ok(ip) = v.parse() { self.server.public_ip = Some(ip); }
} }
if let Ok(v) = std::env::var("PXEFORGE_DHCP_MODE") { if let Ok(v) = std::env::var("OPENPXE_DHCP_PORT") {
if let Ok(p) = v.parse() {
self.network.dhcp_port = p;
}
}
if let Ok(v) = std::env::var("OPENPXE_PUBLIC_IP") {
if let Ok(ip) = v.parse() {
self.server.public_ip = Some(ip);
}
}
if let Ok(v) = std::env::var("OPENPXE_DHCP_MODE") {
self.network.dhcp_mode = match v.to_ascii_lowercase().as_str() { self.network.dhcp_mode = match v.to_ascii_lowercase().as_str() {
"proxy" => DhcpMode::Proxy, "proxy" => DhcpMode::Proxy,
"disabled" | "off" | "none" => DhcpMode::Disabled, "disabled" | "off" | "none" => DhcpMode::Disabled,
_ => self.network.dhcp_mode, _ => self.network.dhcp_mode,
}; };
} }
if let Ok(v) = std::env::var("PXEFORGE_ISO_DIR") { if let Ok(v) = std::env::var("OPENPXE_ISO_DIR") {
self.paths.iso_dir = PathBuf::from(v); self.paths.iso_dir = PathBuf::from(v);
} }
if let Ok(v) = std::env::var("PXEFORGE_WORK_DIR") { if let Ok(v) = std::env::var("OPENPXE_WORK_DIR") {
self.paths.work_dir = PathBuf::from(v); self.paths.work_dir = PathBuf::from(v);
} }
if let Ok(v) = std::env::var("PXEFORGE_IPXE_DIR") { if let Ok(v) = std::env::var("OPENPXE_IPXE_DIR") {
self.paths.ipxe_dir = PathBuf::from(v); self.paths.ipxe_dir = PathBuf::from(v);
} }
if let Ok(v) = std::env::var("PXEFORGE_SMB_DIR") { if let Ok(v) = std::env::var("OPENPXE_SMB_DIR") {
self.paths.smb_dir = PathBuf::from(v); self.paths.smb_dir = PathBuf::from(v);
} }
} }
+294
View File
@@ -0,0 +1,294 @@
//! Per-MAC host bindings.
//!
//! Operators can attach a preferred boot target (a `BootEntry::id`) to a
//! specific MAC address. When a client with that MAC arrives, the top-level
//! boot script chains straight to that target instead of showing the
//! interactive menu.
//!
//! Use cases:
//! - "This rack of Dell servers always images with Ubuntu Server 24.04"
//! - "Tom's laptop always boots from local disk"
//! - "Bench QA machines always boot Memtest until released"
//!
//! Persisted to `<work_dir>/hosts.json`. Like the SettingsStore, on-disk
//! corruption falls back to an empty registry rather than failing
//! startup — a bad hosts file should never block PXE for the network.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use time::OffsetDateTime;
use crate::profile::DeployProfile;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostBinding {
/// Lowercase, colon-separated MAC (e.g. `aa:bb:cc:dd:ee:ff`). The
/// HTTP layer normalizes incoming MACs before lookup so callers
/// don't have to worry about case.
pub mac: String,
/// Preferred boot entry id (matches a `BootEntry::id` in the iso
/// store) OR one of the reserved menu names: `_local`, `_queue`,
/// `_tools_menu`. Empty string falls back to the menu.
pub target: String,
/// Optional human-readable label shown in the UI (`"Tom's laptop"`,
/// `"rack-3 spine"`). Empty if unset.
#[serde(default)]
pub label: String,
/// v0.5.2: optional unattended-install hints (auto hostname / IP /
/// answer-file id). Flattened into the binding JSON so pre-v0.5.2
/// `hosts.json` files (which lack these keys) still deserialize.
#[serde(default, flatten)]
pub profile: DeployProfile,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
}
#[derive(Debug, Default)]
struct Inner {
by_mac: HashMap<String, HostBinding>,
}
/// Registry of per-MAC bindings. Cheap to clone; locks are held
/// briefly. Persistence is best-effort and mirrors `SettingsStore`'s
/// "in-memory authoritative, disk is a cache" policy.
#[derive(Debug, Clone)]
pub struct HostBindings {
path: Arc<PathBuf>,
inner: Arc<RwLock<Inner>>,
}
impl HostBindings {
/// Load from `work_dir/hosts.json`, or start empty if absent /
/// unreadable.
#[must_use]
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("hosts.json");
let inner = match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str::<Vec<HostBinding>>(&text) {
Ok(items) => {
let mut by_mac = HashMap::new();
for b in items {
by_mac.insert(normalize_mac(&b.mac), b);
}
Inner { by_mac }
}
Err(e) => {
tracing::warn!(
target: "openpxe::hosts",
"hosts.json present but unreadable ({e}); starting empty"
);
Inner::default()
}
},
Err(_) => Inner::default(),
};
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(inner)),
}
}
/// Look up a binding by MAC. Match is case-insensitive and tolerates
/// `-` or `:` separators.
#[must_use]
pub fn lookup(&self, mac: &str) -> Option<HostBinding> {
self.inner.read().by_mac.get(&normalize_mac(mac)).cloned()
}
/// Insert or update. Returns the resulting binding (with timestamps).
/// The `profile` carries optional unattended-install hints (v0.5.2);
/// pass `DeployProfile::default()` for a plain pin.
pub fn upsert(
&self,
mac: &str,
target: &str,
label: &str,
profile: DeployProfile,
) -> HostBinding {
let key = normalize_mac(mac);
let now = OffsetDateTime::now_utc();
let profile = profile.normalized();
let binding = {
let mut g = self.inner.write();
let entry = g.by_mac.entry(key.clone()).or_insert_with(|| HostBinding {
mac: key.clone(),
target: target.to_string(),
label: label.to_string(),
profile: profile.clone(),
created_at: now,
updated_at: now,
});
entry.target = target.to_string();
entry.label = label.to_string();
entry.profile = profile.clone();
entry.updated_at = now;
entry.clone()
};
self.persist();
binding
}
/// Remove a binding. Returns true if something was removed.
pub fn remove(&self, mac: &str) -> bool {
let key = normalize_mac(mac);
let removed = self.inner.write().by_mac.remove(&key).is_some();
if removed {
self.persist();
}
removed
}
#[must_use]
pub fn list(&self) -> Vec<HostBinding> {
let g = self.inner.read();
let mut v: Vec<_> = g.by_mac.values().cloned().collect();
v.sort_by(|a, b| a.mac.cmp(&b.mac));
v
}
#[must_use]
pub fn len(&self) -> usize {
self.inner.read().by_mac.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
fn persist(&self) {
let items: Vec<HostBinding> = self.inner.read().by_mac.values().cloned().collect();
let body = match serde_json::to_vec_pretty(&items) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::hosts", "serialize hosts.json: {e}");
return;
}
};
let tmp = self.path.with_extension("json.tmp");
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::hosts", "write hosts.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::warn!(target: "openpxe::hosts", "rename hosts.json: {e}");
}
}
}
/// Lowercase a MAC and normalise `-` separators to `:`. We never strip
/// the separator entirely — `aabbccddeeff` formats are rejected at the
/// HTTP layer because they're ambiguous (could be a device id).
#[must_use]
pub fn normalize_mac(mac: &str) -> String {
mac.trim().to_ascii_lowercase().replace('-', ":")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn np() -> DeployProfile {
DeployProfile::default()
}
#[test]
fn normalize_handles_case_and_dashes() {
assert_eq!(normalize_mac("AA:BB:CC:DD:EE:FF"), "aa:bb:cc:dd:ee:ff");
assert_eq!(normalize_mac("aa-bb-cc-dd-ee-ff"), "aa:bb:cc:dd:ee:ff");
assert_eq!(normalize_mac(" aA-Bb-CC:DD-ee:fF "), "aa:bb:cc:dd:ee:ff");
}
#[test]
fn upsert_then_lookup() {
let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path());
assert!(h.is_empty());
h.upsert(
"AA:BB:CC:00:00:01",
"ubuntu-24-04-linux",
"rack-3 spine",
np(),
);
let found = h.lookup("aa-bb-cc-00-00-01").expect("lookup");
assert_eq!(found.target, "ubuntu-24-04-linux");
assert_eq!(found.label, "rack-3 spine");
assert_eq!(found.mac, "aa:bb:cc:00:00:01");
}
#[test]
fn upsert_replaces_existing_target() {
let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path());
h.upsert("aa:bb:cc:00:00:01", "old-target", "label1", np());
h.upsert("aa:bb:cc:00:00:01", "new-target", "label2", np());
assert_eq!(h.len(), 1);
let b = h.lookup("aa:bb:cc:00:00:01").unwrap();
assert_eq!(b.target, "new-target");
assert_eq!(b.label, "label2");
}
#[test]
fn remove_works_and_reports_outcome() {
let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path());
h.upsert("aa:bb:cc:00:00:01", "x", "", np());
assert!(h.remove("AA:BB:CC:00:00:01"));
assert!(!h.remove("aa:bb:cc:00:00:01")); // already gone
assert!(h.is_empty());
}
#[test]
fn round_trip_persists_to_disk() {
let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path());
h.upsert("aa:bb:cc:00:00:01", "ubuntu-linux", "rack-3", np());
h.upsert("aa:bb:cc:00:00:02", "_local", "tom-laptop", np());
drop(h);
let h2 = HostBindings::load_or_default(dir.path());
assert_eq!(h2.len(), 2);
assert_eq!(h2.lookup("aa:bb:cc:00:00:02").unwrap().target, "_local");
}
#[test]
fn profile_round_trips_to_disk() {
let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path());
let prof = DeployProfile {
auto_hostname: Some("node-7".into()),
auto_ip: Some("10.0.0.7".into()),
unattended_file: Some("ubuntu-ks".into()),
};
h.upsert("aa:bb:cc:00:00:09", "ubuntu-linux", "lab", prof);
drop(h);
let h2 = HostBindings::load_or_default(dir.path());
let b = h2.lookup("aa:bb:cc:00:00:09").unwrap();
assert_eq!(b.profile.auto_hostname.as_deref(), Some("node-7"));
assert_eq!(b.profile.auto_ip.as_deref(), Some("10.0.0.7"));
assert_eq!(b.profile.unattended_file.as_deref(), Some("ubuntu-ks"));
}
#[test]
fn legacy_hosts_json_without_profile_still_loads() {
// A pre-v0.5.2 hosts.json has no profile keys at all.
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("hosts.json"),
br#"[{"mac":"aa:bb:cc:00:00:01","target":"_local","label":"old","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}]"#,
)
.unwrap();
let h = HostBindings::load_or_default(dir.path());
let b = h.lookup("aa:bb:cc:00:00:01").unwrap();
assert_eq!(b.target, "_local");
assert!(b.profile.is_empty());
}
}
+23 -4
View File
@@ -1,19 +1,38 @@
//! PXEForge shared core: config, arch detection, client state registry, //! OpenPXE shared core: config, arch detection, client state registry,
//! runtime settings, and the Gated Deployment queue. //! runtime settings, and the Queued Deployment queue.
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
pub mod arch; pub mod arch;
pub mod auth;
pub mod boot_log;
pub mod branding;
pub mod client; pub mod client;
pub mod config; pub mod config;
pub mod error; pub mod error;
pub mod gate; pub mod host_bindings;
pub mod log_bus; pub mod log_bus;
pub mod metrics;
pub mod notify;
pub mod profile;
pub mod queue;
pub mod saml;
pub mod settings; pub mod settings;
pub mod sso;
pub mod wol;
pub use arch::{ClientArch, FirmwareClass}; pub use arch::{ClientArch, FirmwareClass};
pub use auth::{AdminAccount, AdminPublic, AdminStore};
pub use boot_log::{BootEvent, BootLog};
pub use branding::{ext_for_mime, BrandingStore, LogoSlot, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES};
pub use client::{ClientEvent, ClientRegistry, ClientSnapshot}; pub use client::{ClientEvent, ClientRegistry, ClientSnapshot};
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig}; pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
pub use error::{Error, Result}; pub use error::{Error, Result};
pub use gate::{Gate, GateQueue}; pub use host_bindings::{normalize_mac, HostBinding, HostBindings};
pub use log_bus::{LogBus, LogBusLayer, LogLine}; pub use log_bus::{LogBus, LogBusLayer, LogLine};
pub use metrics::{HttpRoute, Metrics};
pub use notify::{NotifyConfig, NotifyKind, NotifyStore};
pub use profile::DeployProfile;
pub use queue::{DeploymentQueue, QueueEntry};
pub use saml::{IdpMetadata, SamlError, SpParams, VerifiedPrincipal, VerifiedResponse};
pub use settings::{Settings, SettingsStore, TimeoutAction}; pub use settings::{Settings, SettingsStore, TimeoutAction};
pub use sso::{SsoConfig, SsoStore};
+1 -1
View File
@@ -38,7 +38,7 @@ impl LogLine {
/// Compact one-line "tail -f"-style render. /// Compact one-line "tail -f"-style render.
#[must_use] #[must_use]
pub fn render(&self) -> String { pub fn render(&self) -> String {
// 2026-04-29T12:34:56Z [info] pxeforge::http: HTTP listening on 0.0.0.0:80 // 2026-04-29T12:34:56Z [info] openpxe::http: HTTP listening on 0.0.0.0:80
let ts = self let ts = self
.timestamp .timestamp
.format(&time::format_description::well_known::Rfc3339) .format(&time::format_description::well_known::Rfc3339)
+334
View File
@@ -0,0 +1,334 @@
//! Tiny lock-free Prometheus-compatible metrics.
//!
//! We don't pull in `prometheus` or `metrics-rs` for this — they bring
//! their own runtime, registry, and complexity. OpenPXE has a fixed,
//! tiny set of counters/gauges and the exposition format is plain text.
//! A handful of `AtomicU64`s and a `Display` impl gets us everything
//! Prometheus / Grafana / VictoriaMetrics needs to scrape:
//!
//! openpxe_dhcp_replies_total counter (per arch label)
//! openpxe_tftp_transfers_total counter (per status label)
//! openpxe_tftp_bytes_total counter
//! openpxe_http_requests_total counter (per route label)
//! openpxe_iso_count gauge
//! openpxe_client_count gauge
//! openpxe_queue_count gauge
//! openpxe_queue_imaging gauge
//! openpxe_uptime_seconds gauge
//! openpxe_build_info{version} gauge (always 1)
//!
//! Cheap to clone — internal state is a couple of arcs. Counters use
//! `Relaxed` ordering: we don't synchronise across counters, just need
//! per-counter monotonicity.
use std::fmt::Write as _;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
#[derive(Debug, Default)]
#[allow(clippy::struct_field_names)]
struct Inner {
// DHCP proxy
dhcp_replies_legacy: AtomicU64,
dhcp_replies_uefi: AtomicU64,
dhcp_replies_arm64: AtomicU64,
dhcp_replies_unknown: AtomicU64,
dhcp_declined: AtomicU64,
// TFTP
tftp_transfers_ok: AtomicU64,
tftp_transfers_err: AtomicU64,
tftp_bytes: AtomicU64,
// HTTP
http_boot_script: AtomicU64,
http_iso_range: AtomicU64,
http_iso_inner: AtomicU64,
http_ipxe_binary: AtomicU64,
http_api: AtomicU64,
// Gauges (set explicitly; not cumulative)
iso_count: AtomicU64,
client_count: AtomicU64,
queue_count: AtomicU64,
queue_imaging: AtomicU64,
nfs_mounts_active: AtomicU64,
}
#[derive(Debug, Clone, Default)]
pub struct Metrics {
inner: Arc<Inner>,
}
impl Metrics {
#[must_use]
pub fn new() -> Self {
Self::default()
}
// ── DHCP ───────────────────────────────────────────────────────────
pub fn record_dhcp_reply(&self, arch: &str) {
let counter = match arch {
"bios" => &self.inner.dhcp_replies_legacy,
"uefi-x64" | "uefi-ia32" => &self.inner.dhcp_replies_uefi,
"uefi-arm64" => &self.inner.dhcp_replies_arm64,
_ => &self.inner.dhcp_replies_unknown,
};
counter.fetch_add(1, Ordering::Relaxed);
}
pub fn record_dhcp_decline(&self) {
self.inner.dhcp_declined.fetch_add(1, Ordering::Relaxed);
}
// ── TFTP ───────────────────────────────────────────────────────────
pub fn record_tftp_ok(&self, bytes: u64) {
self.inner.tftp_transfers_ok.fetch_add(1, Ordering::Relaxed);
self.inner.tftp_bytes.fetch_add(bytes, Ordering::Relaxed);
}
pub fn record_tftp_err(&self) {
self.inner
.tftp_transfers_err
.fetch_add(1, Ordering::Relaxed);
}
// ── HTTP ───────────────────────────────────────────────────────────
pub fn record_http(&self, route: HttpRoute) {
let counter = match route {
HttpRoute::BootScript => &self.inner.http_boot_script,
HttpRoute::IsoRange => &self.inner.http_iso_range,
HttpRoute::IsoInner => &self.inner.http_iso_inner,
HttpRoute::IpxeBinary => &self.inner.http_ipxe_binary,
HttpRoute::Api => &self.inner.http_api,
};
counter.fetch_add(1, Ordering::Relaxed);
}
// ── Gauges ─────────────────────────────────────────────────────────
pub fn set_iso_count(&self, n: u64) {
self.inner.iso_count.store(n, Ordering::Relaxed);
}
pub fn set_client_count(&self, n: u64) {
self.inner.client_count.store(n, Ordering::Relaxed);
}
pub fn set_queue_counts(&self, total: u64, imaging: u64) {
self.inner.queue_count.store(total, Ordering::Relaxed);
self.inner.queue_imaging.store(imaging, Ordering::Relaxed);
}
pub fn set_nfs_active(&self, n: u64) {
self.inner.nfs_mounts_active.store(n, Ordering::Relaxed);
}
/// Render in the Prometheus text exposition format.
/// Uptime is supplied by the caller because `Metrics` doesn't own
/// the start instant; the HTTP layer does.
#[must_use]
pub fn render(&self, version: &str, uptime_secs: u64) -> String {
let mut out = String::with_capacity(2048);
let i = &self.inner;
// Helper closures.
let write_counter = |o: &mut String, name: &str, help: &str, val: u64, lbl: &str| {
let _ = writeln!(o, "# HELP {name} {help}");
let _ = writeln!(o, "# TYPE {name} counter");
if lbl.is_empty() {
let _ = writeln!(o, "{name} {val}");
} else {
let _ = writeln!(o, "{name}{{{lbl}}} {val}");
}
};
let write_gauge = |o: &mut String, name: &str, help: &str, val: u64, lbl: &str| {
let _ = writeln!(o, "# HELP {name} {help}");
let _ = writeln!(o, "# TYPE {name} gauge");
if lbl.is_empty() {
let _ = writeln!(o, "{name} {val}");
} else {
let _ = writeln!(o, "{name}{{{lbl}}} {val}");
}
};
// Counters with one HELP/TYPE per metric name and per-label rows.
let _ = writeln!(out, "# HELP openpxe_dhcp_replies_total Number of proxyDHCP replies sent, by client architecture.");
let _ = writeln!(out, "# TYPE openpxe_dhcp_replies_total counter");
let _ = writeln!(
out,
"openpxe_dhcp_replies_total{{arch=\"bios\"}} {}",
i.dhcp_replies_legacy.load(Ordering::Relaxed)
);
let _ = writeln!(
out,
"openpxe_dhcp_replies_total{{arch=\"uefi\"}} {}",
i.dhcp_replies_uefi.load(Ordering::Relaxed)
);
let _ = writeln!(
out,
"openpxe_dhcp_replies_total{{arch=\"arm64\"}} {}",
i.dhcp_replies_arm64.load(Ordering::Relaxed)
);
let _ = writeln!(
out,
"openpxe_dhcp_replies_total{{arch=\"unknown\"}} {}",
i.dhcp_replies_unknown.load(Ordering::Relaxed)
);
write_counter(
&mut out,
"openpxe_dhcp_declined_total",
"DHCP requests we saw but did not reply to (mac filter, arch unsupported, etc).",
i.dhcp_declined.load(Ordering::Relaxed),
"",
);
let _ = writeln!(
out,
"# HELP openpxe_tftp_transfers_total TFTP transfers, by status."
);
let _ = writeln!(out, "# TYPE openpxe_tftp_transfers_total counter");
let _ = writeln!(
out,
"openpxe_tftp_transfers_total{{status=\"ok\"}} {}",
i.tftp_transfers_ok.load(Ordering::Relaxed)
);
let _ = writeln!(
out,
"openpxe_tftp_transfers_total{{status=\"err\"}} {}",
i.tftp_transfers_err.load(Ordering::Relaxed)
);
write_counter(
&mut out,
"openpxe_tftp_bytes_total",
"Total bytes successfully delivered over TFTP.",
i.tftp_bytes.load(Ordering::Relaxed),
"",
);
let _ = writeln!(
out,
"# HELP openpxe_http_requests_total HTTP requests served, by route family."
);
let _ = writeln!(out, "# TYPE openpxe_http_requests_total counter");
for (label, counter) in [
("boot_script", &i.http_boot_script),
("iso_range", &i.http_iso_range),
("iso_inner", &i.http_iso_inner),
("ipxe_binary", &i.http_ipxe_binary),
("api", &i.http_api),
] {
let _ = writeln!(
out,
"openpxe_http_requests_total{{route=\"{label}\"}} {}",
counter.load(Ordering::Relaxed)
);
}
// Gauges.
write_gauge(
&mut out,
"openpxe_iso_count",
"ISOs currently registered (local + NFS).",
i.iso_count.load(Ordering::Relaxed),
"",
);
write_gauge(
&mut out,
"openpxe_client_count",
"PXE clients seen this process lifetime.",
i.client_count.load(Ordering::Relaxed),
"",
);
write_gauge(
&mut out,
"openpxe_queue_count",
"Clients currently waiting at the deployment queue.",
i.queue_count.load(Ordering::Relaxed),
"",
);
write_gauge(
&mut out,
"openpxe_queue_imaging",
"Clients currently imaging (queue + assigned target).",
i.queue_imaging.load(Ordering::Relaxed),
"",
);
write_gauge(
&mut out,
"openpxe_nfs_mounts_active",
"NFS shares currently mounted.",
i.nfs_mounts_active.load(Ordering::Relaxed),
"",
);
write_gauge(
&mut out,
"openpxe_uptime_seconds",
"Seconds since this OpenPXE instance started.",
uptime_secs,
"",
);
let _ = writeln!(
out,
"# HELP openpxe_build_info Build metadata. Always 1; the version is in the label."
);
let _ = writeln!(out, "# TYPE openpxe_build_info gauge");
let _ = writeln!(out, "openpxe_build_info{{version=\"{version}\"}} 1");
out
}
}
/// Stable label values for the HTTP route counter. Adding a new route
/// here without updating `record_http` will break compilation, which is
/// exactly the safety we want — Prometheus alerts on cardinality drift,
/// so accidental new label values matter.
#[derive(Debug, Clone, Copy)]
pub enum HttpRoute {
BootScript,
IsoRange,
IsoInner,
IpxeBinary,
Api,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_emits_each_metric_family_once() {
let m = Metrics::new();
m.record_dhcp_reply("uefi-x64");
m.record_dhcp_reply("bios");
m.record_tftp_ok(1024);
m.record_http(HttpRoute::Api);
m.set_iso_count(3);
let out = m.render("0.2.0", 42);
assert_eq!(
out.matches("# TYPE openpxe_dhcp_replies_total counter")
.count(),
1
);
assert_eq!(out.matches("# TYPE openpxe_iso_count gauge").count(), 1);
assert!(out.contains("openpxe_dhcp_replies_total{arch=\"uefi\"} 1"));
assert!(out.contains("openpxe_dhcp_replies_total{arch=\"bios\"} 1"));
assert!(out.contains("openpxe_tftp_transfers_total{status=\"ok\"} 1"));
assert!(out.contains("openpxe_tftp_bytes_total 1024"));
assert!(out.contains("openpxe_http_requests_total{route=\"api\"} 1"));
assert!(out.contains("openpxe_iso_count 3"));
assert!(out.contains("openpxe_uptime_seconds 42"));
assert!(out.contains("openpxe_build_info{version=\"0.2.0\"} 1"));
}
#[test]
fn cloned_metrics_share_state() {
let a = Metrics::new();
let b = a.clone();
a.record_dhcp_reply("bios");
b.record_dhcp_reply("bios");
let out = a.render("test", 0);
assert!(out.contains("openpxe_dhcp_replies_total{arch=\"bios\"} 2"));
}
}
+362
View File
@@ -0,0 +1,362 @@
//! Webhook / email notification configuration.
//!
//! v0.5.0: OpenPXE can ping a chat webhook or send an email when
//! something noteworthy happens (a machine PXE-booted an image, a
//! deployment was assigned, a WoL was sent). One active provider at a
//! time, chosen by `kind` — dead-simple for an L1 tech: pick Slack,
//! paste the incoming-webhook URL, done.
//!
//! This module owns only the *configuration* (validation + persistence
//! to `<work_dir>/notify.json`). The actual sending — HTTP POST for the
//! chat providers, SMTP for email — lives in the http-api crate, which
//! already carries an HTTP client and the SMTP dependency. Keeping the
//! network I/O out of `core` matches how `BrandingStore`/`SsoStore`
//! stay pure config stores.
//!
//! Secrets note: the SMTP password is persisted in `notify.json`
//! alongside the rest of the config (0644 like the other state files).
//! It is never echoed back through the API — the snapshot used for the
//! GET response blanks it (see `Self::redacted`).
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use crate::{Error, Result};
const MAX_URL_LEN: usize = 2048;
const MAX_FIELD_LEN: usize = 512;
/// Which notification transport is active.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NotifyKind {
/// Slack incoming webhook (`{ "text": ... }`).
#[default]
Slack,
/// Discord webhook (`{ "content": ... }`).
Discord,
/// Microsoft Teams incoming webhook (legacy MessageCard JSON).
Teams,
/// Email via SMTP.
Smtp,
}
impl NotifyKind {
/// True when this kind drives a chat webhook (POST a JSON body to a
/// single URL) rather than SMTP.
#[must_use]
pub fn is_webhook(self) -> bool {
matches!(self, Self::Slack | Self::Discord | Self::Teams)
}
}
/// Operator-configurable notification settings. Single provider active
/// at a time; the inactive fields are kept so switching providers
/// doesn't wipe the other one's values.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NotifyConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub kind: NotifyKind,
/// Incoming-webhook URL for Slack / Discord / Teams.
#[serde(default)]
pub webhook_url: String,
// ── SMTP fields (used when kind == Smtp) ──
#[serde(default)]
pub smtp_host: String,
#[serde(default = "default_smtp_port")]
pub smtp_port: u16,
#[serde(default)]
pub smtp_username: String,
#[serde(default)]
pub smtp_password: String,
/// `From:` address. Falls back to `smtp_username` when blank.
#[serde(default)]
pub smtp_from: String,
/// `To:` address (single recipient — keep it simple).
#[serde(default)]
pub smtp_to: String,
/// Use implicit TLS (port 465). When false we use STARTTLS on the
/// configured port (587 typical). Either way the connection is
/// encrypted — we never offer plaintext SMTP.
#[serde(default)]
pub smtp_implicit_tls: bool,
}
fn default_smtp_port() -> u16 {
587
}
impl NotifyConfig {
/// True when enabled and the active provider has the fields it
/// needs to actually send.
#[must_use]
pub fn is_usable(&self) -> bool {
if !self.enabled {
return false;
}
if self.kind.is_webhook() {
!self.webhook_url.trim().is_empty()
} else {
!self.smtp_host.trim().is_empty() && !self.smtp_to.trim().is_empty()
}
}
/// A copy safe to return over the API: the SMTP password is blanked
/// (replaced with a non-empty sentinel only when one is set, so the
/// UI can show "configured" without leaking it).
#[must_use]
pub fn redacted(&self) -> NotifyConfig {
let mut c = self.clone();
if !c.smtp_password.is_empty() {
c.smtp_password = SECRET_SENTINEL.to_string();
}
c
}
}
/// Returned by the API in place of a stored password. When the UI PUTs
/// this value back unchanged we keep the existing password rather than
/// overwriting it with the sentinel.
pub const SECRET_SENTINEL: &str = "__keep__";
/// In-memory + on-disk notification config registry.
#[derive(Debug, Clone)]
pub struct NotifyStore {
path: Arc<PathBuf>,
inner: Arc<RwLock<NotifyConfig>>,
}
impl NotifyStore {
#[must_use]
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("notify.json");
let cfg = match std::fs::read_to_string(&path) {
Ok(text) => serde_json::from_str::<NotifyConfig>(&text).unwrap_or_else(|e| {
tracing::warn!(
target: "openpxe::notify",
"notify.json unreadable ({e}); starting with defaults"
);
NotifyConfig::default()
}),
Err(_) => NotifyConfig::default(),
};
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(cfg)),
}
}
#[must_use]
pub fn snapshot(&self) -> NotifyConfig {
self.inner.read().clone()
}
/// Replace the whole config. `incoming.smtp_password == SECRET_SENTINEL`
/// is treated as "keep the existing password" so the UI never has to
/// round-trip the real secret.
pub fn replace(&self, mut incoming: NotifyConfig) -> Result<NotifyConfig> {
incoming.webhook_url = incoming.webhook_url.trim().to_string();
incoming.smtp_host = incoming.smtp_host.trim().to_string();
incoming.smtp_username = incoming.smtp_username.trim().to_string();
incoming.smtp_from = incoming.smtp_from.trim().to_string();
incoming.smtp_to = incoming.smtp_to.trim().to_string();
// Preserve the stored password when the UI sends the sentinel.
if incoming.smtp_password == SECRET_SENTINEL {
incoming
.smtp_password
.clone_from(&self.inner.read().smtp_password);
}
// Length caps.
if incoming.webhook_url.len() > MAX_URL_LEN {
return Err(Error::Invalid(format!(
"webhook URL exceeds {MAX_URL_LEN}-char cap"
)));
}
for (name, v) in [
("smtp_host", &incoming.smtp_host),
("smtp_username", &incoming.smtp_username),
("smtp_from", &incoming.smtp_from),
("smtp_to", &incoming.smtp_to),
] {
if v.len() > MAX_FIELD_LEN {
return Err(Error::Invalid(format!(
"{name} exceeds {MAX_FIELD_LEN}-char cap"
)));
}
}
// Validate the active provider only when enabling.
if incoming.enabled {
if incoming.kind.is_webhook() {
if incoming.webhook_url.is_empty() {
return Err(Error::Invalid(
"a webhook URL is required to enable chat notifications".into(),
));
}
if !incoming.webhook_url.starts_with("https://")
&& !incoming.webhook_url.starts_with("http://")
{
return Err(Error::Invalid(
"webhook URL must start with http:// or https://".into(),
));
}
} else {
if incoming.smtp_host.is_empty() {
return Err(Error::Invalid(
"SMTP host is required to enable email notifications".into(),
));
}
if incoming.smtp_to.is_empty() {
return Err(Error::Invalid(
"a recipient (To) is required to enable email notifications".into(),
));
}
if incoming.smtp_port == 0 {
return Err(Error::Invalid("SMTP port must be non-zero".into()));
}
}
}
{
let mut g = self.inner.write();
*g = incoming.clone();
}
self.persist();
tracing::info!(
target: "openpxe::notify",
enabled = incoming.enabled, kind = ?incoming.kind,
"notification configuration updated"
);
Ok(incoming)
}
fn persist(&self) {
let snap = self.inner.read().clone();
let body = match serde_json::to_vec_pretty(&snap) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::notify", "serialize notify.json: {e}");
return;
}
};
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = self.path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::notify", "write notify.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::warn!(target: "openpxe::notify", "rename notify.json: {e}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn default_disabled_not_usable() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
assert!(!s.snapshot().enabled);
assert!(!s.snapshot().is_usable());
}
#[test]
fn slack_requires_url_when_enabled() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
let r = s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Slack,
..Default::default()
});
assert!(matches!(r, Err(Error::Invalid(_))));
s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Slack,
webhook_url: "https://hooks.slack.com/services/XXX".into(),
..Default::default()
})
.unwrap();
assert!(s.snapshot().is_usable());
}
#[test]
fn smtp_requires_host_and_recipient() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
let r = s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Smtp,
smtp_host: "smtp.example.com".into(),
..Default::default()
});
assert!(matches!(r, Err(Error::Invalid(_))), "missing recipient should reject");
s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Smtp,
smtp_host: "smtp.example.com".into(),
smtp_port: 587,
smtp_to: "[email protected]".into(),
smtp_from: "[email protected]".into(),
..Default::default()
})
.unwrap();
assert!(s.snapshot().is_usable());
}
#[test]
fn password_sentinel_preserves_stored_secret() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Smtp,
smtp_host: "smtp.example.com".into(),
smtp_port: 587,
smtp_to: "[email protected]".into(),
smtp_password: "s3cret".into(),
..Default::default()
})
.unwrap();
// Redacted snapshot hides the password behind the sentinel.
assert_eq!(s.snapshot().redacted().smtp_password, SECRET_SENTINEL);
// PUTting the sentinel back keeps the real password.
s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Smtp,
smtp_host: "smtp.example.com".into(),
smtp_port: 587,
smtp_to: "[email protected]".into(),
smtp_password: SECRET_SENTINEL.into(),
..Default::default()
})
.unwrap();
assert_eq!(s.snapshot().smtp_password, "s3cret");
}
#[test]
fn webhook_url_scheme_enforced() {
let dir = tempdir().unwrap();
let s = NotifyStore::load_or_default(dir.path());
let r = s.replace(NotifyConfig {
enabled: true,
kind: NotifyKind::Discord,
webhook_url: "ftp://example.com/hook".into(),
..Default::default()
});
assert!(matches!(r, Err(Error::Invalid(_))));
}
}
+122
View File
@@ -0,0 +1,122 @@
//! Per-host deployment profile.
//!
//! v0.5.2: a small, optional bundle of "what should this machine do when
//! it images" attached to either a pinned host binding ([`crate::HostBinding`])
//! or a queued device ([`crate::QueueEntry`]). All three fields are
//! optional and independent:
//!
//! * `auto_hostname` — substituted into the served unattended answer file
//! (`{{HOSTNAME}}`) so the installer sets the machine name.
//! * `auto_ip` — substituted as `{{IP}}`. OpenPXE is a DHCP **proxy** and
//! does not hand out leases, so this is applied by the installer as a
//! static-network directive inside the answer file, not by DHCP.
//! * `unattended_file` — the id of an uploaded file in the unattended
//! store (Kickstart / Preseed / Autoinstall / Windows answer file). When
//! set, the boot chain injects the appropriate kernel argument so the
//! install runs unattended.
use serde::{Deserialize, Serialize};
/// Optional deployment hints carried on a host pin or a queue entry.
///
/// The fields are flattened into `HostBinding` / `QueueEntry` on the wire
/// (so existing JSON stays compatible via `#[serde(default)]`); this type
/// is the in-code bundle the boot chain consumes.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeployProfile {
/// Hostname to set on the imaged machine (`{{HOSTNAME}}`). Empty/None
/// leaves the installer default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_hostname: Option<String>,
/// Static IPv4/IPv6 the installer should configure (`{{IP}}`). Stored
/// as a free-form string — validated lightly at the HTTP layer.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_ip: Option<String>,
/// Id of an uploaded file in the unattended store. Empty/None means
/// "no unattended install — boot interactively".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unattended_file: Option<String>,
}
/// Cap on the stored hostname / IP strings — generous for any real value
/// but bounds what an operator can stuff into the JSON.
pub const MAX_PROFILE_FIELD_LEN: usize = 255;
impl DeployProfile {
/// True when nothing is set — lets call sites skip work entirely.
#[must_use]
pub fn is_empty(&self) -> bool {
self.auto_hostname.is_none() && self.auto_ip.is_none() && self.unattended_file.is_none()
}
/// True when an unattended file is selected (drives boot-chain injection).
#[must_use]
pub fn has_unattended(&self) -> bool {
self.unattended_file
.as_deref()
.is_some_and(|s| !s.trim().is_empty())
}
/// Normalise: trim every field and collapse empty strings to `None`
/// so persisted JSON never carries `""` for an unset value.
#[must_use]
pub fn normalized(mut self) -> Self {
fn clean(v: Option<String>) -> Option<String> {
v.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.map(|s| s.chars().take(MAX_PROFILE_FIELD_LEN).collect())
}
self.auto_hostname = clean(self.auto_hostname);
self.auto_ip = clean(self.auto_ip);
self.unattended_file = clean(self.unattended_file);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_profile_is_empty() {
assert!(DeployProfile::default().is_empty());
assert!(!DeployProfile::default().has_unattended());
}
#[test]
fn normalize_trims_and_nulls_empty() {
let p = DeployProfile {
auto_hostname: Some(" node-7 ".into()),
auto_ip: Some(" ".into()),
unattended_file: Some(String::new()),
}
.normalized();
assert_eq!(p.auto_hostname.as_deref(), Some("node-7"));
assert_eq!(p.auto_ip, None);
assert_eq!(p.unattended_file, None);
assert!(!p.is_empty());
}
#[test]
fn has_unattended_detects_real_id() {
let p = DeployProfile {
unattended_file: Some("ubuntu-ks".into()),
..Default::default()
};
assert!(p.has_unattended());
}
#[test]
fn long_field_is_capped() {
let long = "a".repeat(1000);
let p = DeployProfile {
auto_hostname: Some(long),
..Default::default()
}
.normalized();
assert_eq!(
p.auto_hostname.as_deref().map(str::len),
Some(MAX_PROFILE_FIELD_LEN)
);
}
}
@@ -1,16 +1,16 @@
//! Gated Deployment queue. //! Queued Deployment queue.
//! //!
//! When a client selects "Gated Deployment" at the PXE menu, iPXE POSTs to //! When a client selects "Queued Deployment" at the PXE menu, iPXE POSTs to
//! `/api/gate/join` and receives a gate position. It then enters a poll //! `/api/queue/join` and receives a queue position. It then enters a poll
//! loop hitting `/api/gate/poll/<id>`; the server holds the request open //! loop hitting `/api/queue/poll/<id>`; the server holds the request open
//! until either (a) the operator assigns an ISO from the WebUI, in which //! until either (a) the operator assigns an ISO from the WebUI, in which
//! case the poll returns an iPXE `chain` URL, or (b) the poll times out //! case the poll returns an iPXE `chain` URL, or (b) the poll times out
//! (iPXE's HTTP client has its own timeout), in which case iPXE re-POSTs. //! (iPXE's HTTP client has its own timeout), in which case iPXE re-POSTs.
//! //!
//! The WebUI shows the queue (`GET /api/gate`) and issues //! The WebUI shows the queue (`GET /api/queue`) and issues
//! `POST /api/gate/assign { iso_id, gate_ids: [...] }` to launch a single //! `POST /api/queue/assign { iso_id, entry_ids: [...] }` to launch a single
//! ISO across many gated clients at once. This is the "horse-race gate" //! ISO across many queued clients at once. Every waiting machine receives
//! UX the user asked for — every horse leaves the line simultaneously. //! the assignment without operator visits at the rack.
use parking_lot::RwLock; use parking_lot::RwLock;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -21,13 +21,14 @@ use time::OffsetDateTime;
use tokio::sync::Notify; use tokio::sync::Notify;
use uuid::Uuid; use uuid::Uuid;
use crate::profile::DeployProfile;
use crate::ClientArch; use crate::ClientArch;
/// Per-gate state visible to the WebUI. /// Per-client queue state visible to the WebUI.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Gate { pub struct QueueEntry {
pub id: String, pub id: String,
/// 1-based race-gate position — position 1 is whoever got there first. /// 1-based queue position — position 1 is whoever got there first.
pub position: u32, pub position: u32,
pub mac: String, pub mac: String,
pub ip: Option<IpAddr>, pub ip: Option<IpAddr>,
@@ -37,10 +38,15 @@ pub struct Gate {
#[serde(with = "time::serde::rfc3339")] #[serde(with = "time::serde::rfc3339")]
pub last_poll_at: OffsetDateTime, pub last_poll_at: OffsetDateTime,
pub assigned_target: Option<String>, pub assigned_target: Option<String>,
/// v0.5.2: optional per-device deployment profile set via the queue
/// "Profile" button (auto hostname / IP / unattended file). Flattened
/// so the JSON stays flat alongside the other queue fields.
#[serde(default, flatten)]
pub profile: DeployProfile,
} }
#[derive(Debug)] #[derive(Debug)]
struct GateInner { struct QueueEntryInner {
id: String, id: String,
position: u32, position: u32,
mac: String, mac: String,
@@ -49,14 +55,15 @@ struct GateInner {
joined_at: OffsetDateTime, joined_at: OffsetDateTime,
last_poll_at: OffsetDateTime, last_poll_at: OffsetDateTime,
assigned_target: Option<String>, assigned_target: Option<String>,
profile: DeployProfile,
/// Broadcast primitive that wakes the long-poll as soon as an /// Broadcast primitive that wakes the long-poll as soon as an
/// assignment lands — no polling on our side, no sleep-loops. /// assignment lands — no polling on our side, no sleep-loops.
notify: Arc<Notify>, notify: Arc<Notify>,
} }
impl GateInner { impl QueueEntryInner {
fn snapshot(&self) -> Gate { fn snapshot(&self) -> QueueEntry {
Gate { QueueEntry {
id: self.id.clone(), id: self.id.clone(),
position: self.position, position: self.position,
mac: self.mac.clone(), mac: self.mac.clone(),
@@ -65,39 +72,44 @@ impl GateInner {
joined_at: self.joined_at, joined_at: self.joined_at,
last_poll_at: self.last_poll_at, last_poll_at: self.last_poll_at,
assigned_target: self.assigned_target.clone(), assigned_target: self.assigned_target.clone(),
profile: self.profile.clone(),
} }
} }
} }
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct GateQueue { pub struct DeploymentQueue {
inner: RwLock<HashMap<String, GateInner>>, inner: RwLock<HashMap<String, QueueEntryInner>>,
} }
impl GateQueue { impl DeploymentQueue {
#[must_use] #[must_use]
pub fn new() -> Arc<Self> { pub fn new() -> Arc<Self> {
Arc::new(Self::default()) Arc::new(Self::default())
} }
/// Add a client to the gate. Returns the new `Gate` snapshot. If the /// Add a client to the queue. Returns the current queue snapshot. If the
/// MAC is already queued, the existing gate is returned unchanged — /// MAC is already queued, the existing entry is returned unchanged —
/// retrying iPXE clients don't duplicate their slot. /// retrying iPXE clients don't duplicate their slot.
pub fn join(&self, mac: &str, ip: Option<IpAddr>, arch: Option<ClientArch>) -> Gate { pub fn join(&self, mac: &str, ip: Option<IpAddr>, arch: Option<ClientArch>) -> QueueEntry {
let now = OffsetDateTime::now_utc(); let now = OffsetDateTime::now_utc();
let mut guard = self.inner.write(); let mut guard = self.inner.write();
if let Some(existing) = guard.values_mut().find(|g| g.mac == mac) { if let Some(existing) = guard.values_mut().find(|g| g.mac == mac) {
existing.last_poll_at = now; existing.last_poll_at = now;
if ip.is_some() { existing.ip = ip; } if ip.is_some() {
if arch.is_some() { existing.arch = arch; } existing.ip = ip;
}
if arch.is_some() {
existing.arch = arch;
}
return existing.snapshot(); return existing.snapshot();
} }
// Race position = max(position) + 1, or 1 if empty. // Queue position = max(position) + 1, or 1 if empty.
let next_pos = guard.values().map(|g| g.position).max().unwrap_or(0) + 1; let next_pos = guard.values().map(|g| g.position).max().unwrap_or(0) + 1;
let id = Uuid::new_v4().to_string(); let id = Uuid::new_v4().to_string();
let inner = GateInner { let inner = QueueEntryInner {
id: id.clone(), id: id.clone(),
position: next_pos, position: next_pos,
mac: mac.to_string(), mac: mac.to_string(),
@@ -106,6 +118,7 @@ impl GateQueue {
joined_at: now, joined_at: now,
last_poll_at: now, last_poll_at: now,
assigned_target: None, assigned_target: None,
profile: DeployProfile::default(),
notify: Arc::new(Notify::new()), notify: Arc::new(Notify::new()),
}; };
let snap = inner.snapshot(); let snap = inner.snapshot();
@@ -113,29 +126,52 @@ impl GateQueue {
snap snap
} }
/// Look up the `Notify` primitive for a given gate id, for long-polling. /// Look up the `Notify` primitive for a given queue entry id, for long-polling.
#[must_use] #[must_use]
pub fn notifier(&self, gate_id: &str) -> Option<Arc<Notify>> { pub fn notifier(&self, entry_id: &str) -> Option<Arc<Notify>> {
self.inner.read().get(gate_id).map(|g| g.notify.clone()) self.inner.read().get(entry_id).map(|g| g.notify.clone())
} }
/// Update the last-poll timestamp (keeps the gate's "live" indicator /// Update the last-poll timestamp (keeps the queue's "live" indicator
/// fresh in the UI) and return the current snapshot. Returns None if /// fresh in the UI) and return the current snapshot. Returns None if
/// the gate was released/expired between requests. /// the entry was released/expired between requests.
pub fn touch(&self, gate_id: &str) -> Option<Gate> { pub fn touch(&self, entry_id: &str) -> Option<QueueEntry> {
let mut guard = self.inner.write(); let mut guard = self.inner.write();
let g = guard.get_mut(gate_id)?; let g = guard.get_mut(entry_id)?;
g.last_poll_at = OffsetDateTime::now_utc(); g.last_poll_at = OffsetDateTime::now_utc();
Some(g.snapshot()) Some(g.snapshot())
} }
/// Operator assigns an ISO entry (boot_entry id) to one or more gates. /// Operator sets (or clears) the deployment profile for a queued
/// Returns the number of gates that were updated. Gates not in the /// device via the WebUI "Profile" button. Returns the updated
/// snapshot, or `None` if the entry has since been released.
pub fn set_profile(&self, entry_id: &str, profile: DeployProfile) -> Option<QueueEntry> {
let mut guard = self.inner.write();
let g = guard.get_mut(entry_id)?;
g.profile = profile.normalized();
Some(g.snapshot())
}
/// Look up the deployment profile for a queued MAC, if any. Used by
/// the boot chain to inject an unattended file / template the
/// hostname + IP when an assigned device chains to its target.
#[must_use]
pub fn profile_for_mac(&self, mac: &str) -> Option<DeployProfile> {
let guard = self.inner.read();
guard
.values()
.find(|g| g.mac == mac)
.map(|g| g.profile.clone())
.filter(|p| !p.is_empty())
}
/// Operator assigns an ISO entry (boot_entry id) to one or more clients.
/// Returns the number of queue entries that were updated. Entries not in the
/// queue are silently skipped. /// queue are silently skipped.
pub fn assign(&self, gate_ids: &[String], target: &str) -> usize { pub fn assign(&self, entry_ids: &[String], target: &str) -> usize {
let mut guard = self.inner.write(); let mut guard = self.inner.write();
let mut updated = 0; let mut updated = 0;
for id in gate_ids { for id in entry_ids {
if let Some(g) = guard.get_mut(id) { if let Some(g) = guard.get_mut(id) {
g.assigned_target = Some(target.to_string()); g.assigned_target = Some(target.to_string());
g.notify.notify_waiters(); g.notify.notify_waiters();
@@ -145,11 +181,11 @@ impl GateQueue {
updated updated
} }
/// Remove a gate and return its final snapshot. Called after the client /// Remove a queue entry and return its final snapshot. Called after the client
/// has successfully chained onto its assignment. /// has successfully chained onto its assignment.
pub fn release(&self, gate_id: &str) -> Option<Gate> { pub fn release(&self, entry_id: &str) -> Option<QueueEntry> {
let mut guard = self.inner.write(); let mut guard = self.inner.write();
let g = guard.remove(gate_id)?; let g = guard.remove(entry_id)?;
g.notify.notify_waiters(); g.notify.notify_waiters();
// Renumber positions so the display stays contiguous (1..N). This // Renumber positions so the display stays contiguous (1..N). This
// is O(N) but the queue is expected to be small (dozens of hosts). // is O(N) but the queue is expected to be small (dozens of hosts).
@@ -162,9 +198,9 @@ impl GateQueue {
} }
#[must_use] #[must_use]
pub fn list(&self) -> Vec<Gate> { pub fn list(&self) -> Vec<QueueEntry> {
let guard = self.inner.read(); let guard = self.inner.read();
let mut v: Vec<_> = guard.values().map(GateInner::snapshot).collect(); let mut v: Vec<_> = guard.values().map(QueueEntryInner::snapshot).collect();
v.sort_by_key(|g| g.position); v.sort_by_key(|g| g.position);
v v
} }
@@ -186,7 +222,7 @@ mod tests {
#[test] #[test]
fn join_assigns_sequential_positions() { fn join_assigns_sequential_positions() {
let q = GateQueue::new(); let q = DeploymentQueue::new();
let g1 = q.join("aa:bb:cc:00:00:01", None, None); let g1 = q.join("aa:bb:cc:00:00:01", None, None);
let g2 = q.join("aa:bb:cc:00:00:02", None, None); let g2 = q.join("aa:bb:cc:00:00:02", None, None);
let g3 = q.join("aa:bb:cc:00:00:03", None, None); let g3 = q.join("aa:bb:cc:00:00:03", None, None);
@@ -197,7 +233,7 @@ mod tests {
#[test] #[test]
fn rejoining_same_mac_is_idempotent() { fn rejoining_same_mac_is_idempotent() {
let q = GateQueue::new(); let q = DeploymentQueue::new();
let g1 = q.join("aa:bb:cc:00:00:01", None, None); let g1 = q.join("aa:bb:cc:00:00:01", None, None);
let g2 = q.join("aa:bb:cc:00:00:01", None, None); let g2 = q.join("aa:bb:cc:00:00:01", None, None);
assert_eq!(g1.id, g2.id); assert_eq!(g1.id, g2.id);
@@ -207,7 +243,7 @@ mod tests {
#[test] #[test]
fn assign_broadcasts_target() { fn assign_broadcasts_target() {
let q = GateQueue::new(); let q = DeploymentQueue::new();
let g1 = q.join("aa:bb:cc:00:00:01", None, None); let g1 = q.join("aa:bb:cc:00:00:01", None, None);
let g2 = q.join("aa:bb:cc:00:00:02", None, None); let g2 = q.join("aa:bb:cc:00:00:02", None, None);
let n = q.assign(&[g1.id.clone(), g2.id.clone()], "ubuntu-24-04-linux"); let n = q.assign(&[g1.id.clone(), g2.id.clone()], "ubuntu-24-04-linux");
@@ -219,7 +255,7 @@ mod tests {
#[test] #[test]
fn release_renumbers() { fn release_renumbers() {
let q = GateQueue::new(); let q = DeploymentQueue::new();
let a = q.join("aa:00:00:00:00:01", None, None); let a = q.join("aa:00:00:00:00:01", None, None);
let _b = q.join("aa:00:00:00:00:02", None, None); let _b = q.join("aa:00:00:00:00:02", None, None);
let c = q.join("aa:00:00:00:00:03", None, None); let c = q.join("aa:00:00:00:00:03", None, None);
@@ -234,7 +270,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn assign_wakes_waiter() { async fn assign_wakes_waiter() {
let q = GateQueue::new(); let q = DeploymentQueue::new();
let g = q.join("aa:00:00:00:00:01", None, None); let g = q.join("aa:00:00:00:00:01", None, None);
let notify = q.notifier(&g.id).unwrap(); let notify = q.notifier(&g.id).unwrap();
@@ -246,7 +282,7 @@ mod tests {
q3.touch(&id) q3.touch(&id)
}); });
tokio::time::sleep(std::time::Duration::from_millis(10)).await; tokio::time::sleep(std::time::Duration::from_millis(10)).await;
q2.assign(&[g.id.clone()], "x"); q2.assign(std::slice::from_ref(&g.id), "x");
let result = fut.await.unwrap(); let result = fut.await.unwrap();
assert!(result.is_some()); assert!(result.is_some());
assert_eq!(result.unwrap().assigned_target.as_deref(), Some("x")); assert_eq!(result.unwrap().assigned_target.as_deref(), Some("x"));
+188
View File
@@ -0,0 +1,188 @@
//! AuthnRequest construction + HTTP-Redirect binding encoding.
//!
//! For SP-initiated login we build an `<AuthnRequest>`, then encode it for the
//! HTTP-Redirect binding: raw DEFLATE (RFC 1951) → base64 → percent-encode,
//! appended as the `SAMLRequest` query parameter. AuthnRequests are sent
//! unsigned in this release (the IdP must not require client signatures).
use std::fmt::Write as _;
use std::io::Write as _;
use base64::Engine;
use flate2::write::DeflateEncoder;
use flate2::Compression;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use super::{SamlError, SpParams};
const NS_PROTOCOL: &str = "urn:oasis:names:tc:SAML:2.0:protocol";
const NS_ASSERTION: &str = "urn:oasis:names:tc:SAML:2.0:assertion";
const NAMEID_EMAIL: &str = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
const BINDING_POST: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
/// A built AuthnRequest, ready to redirect the browser to the IdP.
#[derive(Debug, Clone)]
pub struct AuthnRequest {
/// The request `ID` — the caller records this so the matching response's
/// `InResponseTo` can be correlated (replay/CSRF protection).
pub id: String,
/// The full IdP URL to 302 the browser to (includes `SAMLRequest` and,
/// when supplied, `RelayState`).
pub location: String,
}
/// Build an AuthnRequest targeting `idp_sso_url` and encode it for the
/// HTTP-Redirect binding. `relay_state`, if given, round-trips back to us via
/// the response (we use it to send the operator to their intended page).
pub fn build(
sp: &SpParams,
idp_sso_url: &str,
relay_state: Option<&str>,
) -> Result<AuthnRequest, SamlError> {
let id = format!("_{}", uuid::Uuid::new_v4().simple());
let issue_instant = OffsetDateTime::now_utc()
.replace_nanosecond(0)
.unwrap_or_else(|_| OffsetDateTime::now_utc())
.format(&Rfc3339)
.map_err(|e| SamlError::Timestamp(e.to_string()))?;
let xml = format!(
r#"<samlp:AuthnRequest xmlns:samlp="{NS_PROTOCOL}" xmlns:saml="{NS_ASSERTION}" ID="{id}" Version="2.0" IssueInstant="{instant}" Destination="{dest}" ProtocolBinding="{BINDING_POST}" AssertionConsumerServiceURL="{acs}"><saml:Issuer>{issuer}</saml:Issuer><samlp:NameIDPolicy Format="{NAMEID_EMAIL}" AllowCreate="true"/></samlp:AuthnRequest>"#,
instant = issue_instant,
dest = xml_escape(idp_sso_url),
acs = xml_escape(&sp.acs_url),
issuer = xml_escape(&sp.entity_id),
);
let encoded = deflate_base64(&xml)?;
let sep = if idp_sso_url.contains('?') { '&' } else { '?' };
let mut location = format!("{idp_sso_url}{sep}SAMLRequest={}", pct_encode(&encoded));
if let Some(rs) = relay_state {
location.push_str("&RelayState=");
location.push_str(&pct_encode(rs));
}
Ok(AuthnRequest { id, location })
}
/// Raw-DEFLATE then base64 — the HTTP-Redirect binding's `SAMLRequest` payload.
fn deflate_base64(xml: &str) -> Result<String, SamlError> {
let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
enc.write_all(xml.as_bytes())
.and_then(|()| enc.try_finish())
.map_err(|e| SamlError::Xml(format!("deflate: {e}")))?;
let compressed = enc
.finish()
.map_err(|e| SamlError::Xml(format!("deflate: {e}")))?;
Ok(base64::engine::general_purpose::STANDARD.encode(compressed))
}
/// Percent-encode a query-string component (RFC 3986 unreserved set passes
/// through; everything else is `%XX`).
fn pct_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 3);
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
let _ = write!(out, "%{b:02X}");
}
}
}
out
}
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use flate2::read::DeflateDecoder;
use std::io::Read;
fn sp() -> SpParams {
SpParams {
entity_id: "https://pxe.example.com".into(),
acs_url: "https://pxe.example.com/api/sso/acs".into(),
}
}
fn pct_decode(s: &str) -> Vec<u8> {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = (bytes[i + 1] as char).to_digit(16).unwrap();
let lo = (bytes[i + 2] as char).to_digit(16).unwrap();
out.push((hi * 16 + lo) as u8);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
out
}
#[test]
fn id_is_ncname_and_location_has_request() {
let req = build(&sp(), "https://idp.example.com/sso", Some("/dashboard")).unwrap();
assert!(req.id.starts_with('_'));
assert!(req
.location
.starts_with("https://idp.example.com/sso?SAMLRequest="));
assert!(req.location.contains("&RelayState=%2Fdashboard"));
}
#[test]
fn redirect_payload_round_trips_to_our_authn_request() {
let req = build(&sp(), "https://idp.example.com/sso", None).unwrap();
// Pull SAMLRequest value out of the query string.
let q = req.location.split("SAMLRequest=").nth(1).unwrap();
let val = q.split('&').next().unwrap();
let compressed = base64::engine::general_purpose::STANDARD
.decode(pct_decode(val))
.unwrap();
let mut inflate = DeflateDecoder::new(&compressed[..]);
let mut xml = String::new();
inflate.read_to_string(&mut xml).unwrap();
let doc = roxmltree::Document::parse(&xml).unwrap();
let root = doc.root_element();
assert_eq!(root.tag_name().name(), "AuthnRequest");
assert_eq!(root.attribute("ID").unwrap(), req.id);
assert_eq!(
root.attribute("AssertionConsumerServiceURL").unwrap(),
"https://pxe.example.com/api/sso/acs"
);
let issuer = root
.descendants()
.find(|n| n.tag_name().name() == "Issuer")
.unwrap();
assert_eq!(issuer.text().unwrap(), "https://pxe.example.com");
}
#[test]
fn existing_query_uses_ampersand_separator() {
let req = build(&sp(), "https://idp.example.com/sso?foo=bar", None).unwrap();
assert!(req.location.contains("?foo=bar&SAMLRequest="));
}
}
+241
View File
@@ -0,0 +1,241 @@
//! IdP metadata parsing + SP metadata generation.
//!
//! We parse only what the SP flow needs: the IdP Entity ID, its
//! `SingleSignOnService` endpoints (HTTP-Redirect / HTTP-POST), and the
//! X.509 signing certificate(s). Everything else in the document is ignored.
use base64::Engine;
use super::{SamlError, SpParams};
/// SAML 2.0 binding URIs.
pub const BINDING_REDIRECT: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect";
pub const BINDING_POST: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
/// The subset of an IdP's `EntityDescriptor` the SP flow consumes.
#[derive(Debug, Clone)]
pub struct IdpMetadata {
/// The IdP's Entity ID — we require incoming assertions to be issued by it.
pub entity_id: String,
/// SSO endpoint for the HTTP-Redirect binding (where we send AuthnRequests).
pub sso_redirect_url: Option<String>,
/// SSO endpoint for the HTTP-POST binding (fallback target).
pub sso_post_url: Option<String>,
/// DER-encoded X.509 signing certificate(s). More than one appears during
/// key rotation; verification tries each.
pub signing_certs_der: Vec<Vec<u8>>,
}
impl IdpMetadata {
/// Parse an IdP `EntityDescriptor` document.
///
/// Robust to namespace-prefix variation (matches on local element names),
/// since IdPs disagree on prefixes (`md:`, `ns0:`, default, …).
pub fn parse(xml: &str) -> Result<Self, SamlError> {
let doc = roxmltree::Document::parse(xml).map_err(|e| SamlError::Xml(e.to_string()))?;
let root = doc.root_element();
// The signing IDP descriptor. Some metadata wraps multiple
// descriptors (AA, SP) in one document; we want IDPSSODescriptor.
let idp_desc = root
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "IDPSSODescriptor")
.ok_or_else(|| SamlError::Metadata("IDPSSODescriptor".into()))?;
// Entity ID lives on the EntityDescriptor (root, or an ancestor of the
// IDPSSODescriptor when several are nested).
let entity_id = idp_desc
.ancestors()
.find_map(|n| {
if n.tag_name().name() == "EntityDescriptor" {
n.attribute("entityID")
} else {
None
}
})
.or_else(|| root.attribute("entityID"))
.map(str::to_owned)
.ok_or_else(|| SamlError::Metadata("entityID".into()))?;
let mut sso_redirect_url = None;
let mut sso_post_url = None;
for sso in idp_desc
.children()
.filter(|n| n.is_element() && n.tag_name().name() == "SingleSignOnService")
{
let binding = sso.attribute("Binding").unwrap_or("");
let location = sso.attribute("Location").map(str::to_owned);
match binding {
BINDING_REDIRECT if sso_redirect_url.is_none() => sso_redirect_url = location,
BINDING_POST if sso_post_url.is_none() => sso_post_url = location,
_ => {}
}
}
// Signing certs: KeyDescriptor with use="signing" or no use attribute
// (a bare KeyDescriptor is valid for both signing and encryption).
let mut signing_certs_der = Vec::new();
for kd in idp_desc
.children()
.filter(|n| n.is_element() && n.tag_name().name() == "KeyDescriptor")
{
match kd.attribute("use") {
Some("signing") | None => {}
Some(_) => continue, // encryption-only key — skip
}
for cert_node in kd
.descendants()
.filter(|n| n.is_element() && n.tag_name().name() == "X509Certificate")
{
let b64: String = node_text(&cert_node)
.chars()
.filter(|c| !c.is_whitespace())
.collect();
if b64.is_empty() {
continue;
}
let der = base64::engine::general_purpose::STANDARD
.decode(b64.as_bytes())
.map_err(|e| SamlError::Base64(e.to_string()))?;
signing_certs_der.push(der);
}
}
if signing_certs_der.is_empty() {
return Err(SamlError::NoSigningCert);
}
Ok(Self {
entity_id,
sso_redirect_url,
sso_post_url,
signing_certs_der,
})
}
/// Preferred SSO destination for an outbound AuthnRequest: HTTP-Redirect
/// if advertised, otherwise HTTP-POST.
pub fn sso_destination(&self) -> Option<&str> {
self.sso_redirect_url
.as_deref()
.or(self.sso_post_url.as_deref())
}
}
/// Build our SP `EntityDescriptor` XML so an IdP admin can import OpenPXE as a
/// relying party. Advertises the ACS URL (HTTP-POST binding) and an emailAddress
/// NameID format — matching what the response path expects.
pub fn build_sp_metadata(sp: &SpParams) -> String {
let entity = xml_escape(&sp.entity_id);
let acs = xml_escape(&sp.acs_url);
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{entity}">
<SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
<AssertionConsumerService Binding="{BINDING_POST}" Location="{acs}" index="0" isDefault="true"/>
</SPSSODescriptor>
</EntityDescriptor>
"#
)
}
/// Collect the concatenated text of an element's direct text children.
fn node_text(n: &roxmltree::Node<'_, '_>) -> String {
n.children()
.filter(roxmltree::Node::is_text)
.filter_map(|c| c.text())
.collect()
}
/// Minimal XML attribute/text escaping for the values we interpolate.
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
// A trimmed-down Keycloak-style IdP descriptor (cert body is a stand-in;
// signing tests build real certs in the parent module's tests).
const SAMPLE: &str = r#"<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
entityID="https://idp.example.com/realms/fleet">
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo><ds:X509Data><ds:X509Certificate>
QUJDREVG
</ds:X509Certificate></ds:X509Data></ds:KeyInfo>
</md:KeyDescriptor>
<md:KeyDescriptor use="encryption">
<ds:KeyInfo><ds:X509Data><ds:X509Certificate>WlpaWg==</ds:X509Certificate></ds:X509Data></ds:KeyInfo>
</md:KeyDescriptor>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="https://idp.example.com/realms/fleet/protocol/saml"/>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://idp.example.com/realms/fleet/protocol/saml"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>"#;
#[test]
fn parses_entity_sso_and_signing_cert() {
let m = IdpMetadata::parse(SAMPLE).unwrap();
assert_eq!(m.entity_id, "https://idp.example.com/realms/fleet");
assert_eq!(
m.sso_redirect_url.as_deref(),
Some("https://idp.example.com/realms/fleet/protocol/saml")
);
assert!(m.sso_post_url.is_some());
// Only the signing KeyDescriptor's cert is collected (ABCDEF), not the
// encryption one (ZZZZ).
assert_eq!(m.signing_certs_der.len(), 1);
assert_eq!(m.signing_certs_der[0], b"ABCDEF");
}
#[test]
fn missing_signing_cert_is_rejected() {
let xml = r#"<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="x">
<IDPSSODescriptor>
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://x/sso"/>
</IDPSSODescriptor></EntityDescriptor>"#;
assert!(matches!(
IdpMetadata::parse(xml),
Err(SamlError::NoSigningCert)
));
}
#[test]
fn missing_idp_descriptor_is_rejected() {
let xml = r#"<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="x"></EntityDescriptor>"#;
assert!(matches!(
IdpMetadata::parse(xml),
Err(SamlError::Metadata(_))
));
}
#[test]
fn sp_metadata_contains_entity_and_acs() {
let sp = SpParams {
entity_id: "https://pxe.example.com".into(),
acs_url: "https://pxe.example.com/api/sso/acs".into(),
};
let xml = build_sp_metadata(&sp);
assert!(xml.contains(r#"entityID="https://pxe.example.com""#));
assert!(xml.contains("https://pxe.example.com/api/sso/acs"));
assert!(xml.contains(BINDING_POST));
// Must be well-formed.
roxmltree::Document::parse(&xml).unwrap();
}
}
+92
View File
@@ -0,0 +1,92 @@
//! Pure-Rust SAML 2.0 Service Provider (v0.5.1).
//!
//! This module implements the SP half of a SAML Web-Browser-SSO profile:
//!
//! * [`metadata`] — parse the IdP's `EntityDescriptor` (SSO URLs + signing
//! certificates) and build *our* SP metadata for the IdP admin to import.
//! * [`authn_request`] — build an `AuthnRequest` and encode it for the
//! HTTP-Redirect binding.
//! * [`response`] — decode a `SAMLResponse`, **verify its XML signature**
//! against the IdP's pinned certificate (via the pure-Rust `bergshamra`
//! crate — no OpenSSL/libxml2/xmlsec, so the static musl binary stays
//! C-free), then enforce the SP-side semantic checks (Status, Destination,
//! Audience, time bounds) that are where SAML SPs actually get attacked.
//!
//! Stateful checks (replay of assertion IDs, correlating `InResponseTo`
//! against requests *we* issued, gating IdP-initiated login) live in the
//! HTTP layer — [`response::consume`] is deliberately stateless and returns
//! the IDs the caller needs to perform them.
//!
//! Access model: any assertion the IdP authenticates and we cryptographically
//! verify yields an operator [`VerifiedPrincipal`]. OpenPXE is single-tier —
//! there is no per-user role table — and the local admin account remains a
//! guaranteed fallback owner regardless of SSO state.
pub mod authn_request;
pub mod metadata;
pub mod response;
pub use authn_request::AuthnRequest;
pub use metadata::IdpMetadata;
pub use response::{VerifiedPrincipal, VerifiedResponse};
use thiserror::Error;
/// Default clock-skew tolerance applied to assertion time bounds. SAML IdPs
/// and SPs rarely have perfectly synced clocks; 60s matches common practice
/// (Shibboleth/FleetDM defaults are in this ballpark).
pub const DEFAULT_CLOCK_SKEW_SECS: i64 = 60;
/// Runtime SP parameters, derived from [`crate::SsoConfig`] + the advertised
/// public base URL by the HTTP layer.
#[derive(Debug, Clone)]
pub struct SpParams {
/// Our SP Entity ID (the `<Issuer>` we send and the `Audience` we require
/// in responses). Defaults to the public base URL when the operator left
/// the Entity ID field blank.
pub entity_id: String,
/// The Assertion Consumer Service URL the IdP POSTs the response to —
/// `<public_base_url>/api/sso/acs`.
pub acs_url: String,
}
/// Everything that can go wrong consuming a SAML response. Kept coarse on
/// purpose: the HTTP layer logs the detail and shows the operator a generic
/// "SSO sign-in failed" — we never leak which specific check tripped to the
/// browser, since that aids an attacker probing the SP.
#[derive(Debug, Error)]
pub enum SamlError {
#[error("SAML XML parse error: {0}")]
Xml(String),
#[error("IdP metadata is missing a required element: {0}")]
Metadata(String),
#[error("no usable IdP signing certificate in metadata")]
NoSigningCert,
#[error("signature verification failed: {0}")]
Signature(String),
#[error("the signature does not cover the assertion we read")]
SignatureScope,
#[error("SAML response status was not Success: {0}")]
Status(String),
#[error("response is missing a required element: {0}")]
MissingElement(String),
#[error("encrypted assertions are not supported in this release")]
EncryptedAssertionUnsupported,
#[error("expected exactly one assertion, found {0}")]
AssertionCount(usize),
#[error("issuer mismatch: response was not issued by the configured IdP")]
IssuerMismatch,
#[error("audience mismatch: assertion is not addressed to this service provider")]
AudienceMismatch,
#[error("response destination does not match our ACS URL")]
DestinationMismatch,
#[error("assertion is expired or not yet valid")]
TimeBounds,
#[error("invalid SAML timestamp: {0}")]
Timestamp(String),
#[error("base64 decode failed: {0}")]
Base64(String),
}
#[cfg(test)]
mod tests;
+294
View File
@@ -0,0 +1,294 @@
//! SAMLResponse consumption: signature verification + SP-side validation.
//!
//! [`consume`] is intentionally **stateless** — it verifies the XML signature
//! against the IdP's pinned certificate(s) and enforces every check that can
//! be made from the response alone (Status, Destination, Issuer, Audience,
//! time bounds, signature scope). It then returns the `assertion_id` and
//! `in_response_to` so the HTTP layer can perform the *stateful* checks it
//! owns: replay rejection, correlating the request we issued, and gating
//! IdP-initiated login.
use roxmltree::{Document, Node};
use time::format_description::well_known::Rfc3339;
use time::{Duration, OffsetDateTime};
use super::metadata::IdpMetadata;
use super::{SamlError, SpParams};
const STATUS_SUCCESS: &str = "urn:oasis:names:tc:SAML:2.0:status:Success";
/// The verified subject of a SAML assertion. OpenPXE is single-tier, so this
/// is all an operator session needs.
#[derive(Debug, Clone)]
pub struct VerifiedPrincipal {
/// The `<NameID>` value (an email, per our requested NameID format).
pub name_id: String,
/// Email used as the session identity. Equals `name_id` for the
/// emailAddress NameID format.
pub email: String,
/// Human-readable display name, if the IdP sent one as an attribute.
pub display_name: Option<String>,
}
/// Result of a successful [`consume`]. The IDs/expiry feed the HTTP layer's
/// stateful checks.
#[derive(Debug, Clone)]
pub struct VerifiedResponse {
pub principal: VerifiedPrincipal,
/// `InResponseTo` from the response, if present. `None` = unsolicited
/// (IdP-initiated) — the HTTP layer only accepts that when the operator
/// enabled it.
pub in_response_to: Option<String>,
/// The assertion's `ID` — used by the caller as the replay-guard key.
pub assertion_id: String,
/// The assertion's expiry (`Conditions/@NotOnOrAfter`) — the replay
/// guard can drop the consumed ID after this instant.
pub assertion_expiry: OffsetDateTime,
/// `AuthnStatement/@SessionIndex`, if present (useful for future SLO).
pub session_index: Option<String>,
}
/// Verify and validate a decoded `SAMLResponse` XML document.
pub fn consume(
xml: &str,
sp: &SpParams,
idp: &IdpMetadata,
now: OffsetDateTime,
clock_skew: Duration,
) -> Result<VerifiedResponse, SamlError> {
// 1. Cryptographically verify the signature against the pinned IdP cert(s).
// `trusted_keys_only` ignores any cert embedded in the document's
// KeyInfo, so an attacker can't substitute their own key.
let verified_uris = verify_signature(xml, &idp.signing_certs_der)?;
// 2. Parse for semantic validation.
let doc = Document::parse(xml).map_err(|e| SamlError::Xml(e.to_string()))?;
let root = doc.root_element();
if root.tag_name().name() != "Response" {
return Err(SamlError::MissingElement("Response".into()));
}
let response_id = root.attribute("ID").map(str::to_owned);
let in_response_to = root.attribute("InResponseTo").map(str::to_owned);
// 3. Status must be Success.
let status_value = root
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "StatusCode")
.and_then(|sc| sc.attribute("Value"))
.unwrap_or("");
if status_value != STATUS_SUCCESS && !status_value.ends_with(":Success") {
return Err(SamlError::Status(status_value.to_owned()));
}
// 4. Destination (if the IdP set one) must be our ACS.
if let Some(dest) = root.attribute("Destination") {
if !urls_equal(dest, &sp.acs_url) {
return Err(SamlError::DestinationMismatch);
}
}
// 5. Exactly one (unencrypted) Assertion.
if root
.descendants()
.any(|n| n.is_element() && n.tag_name().name() == "EncryptedAssertion")
{
return Err(SamlError::EncryptedAssertionUnsupported);
}
let assertions: Vec<Node<'_, '_>> = root
.children()
.filter(|c| c.is_element() && c.tag_name().name() == "Assertion")
.collect();
if assertions.len() != 1 {
return Err(SamlError::AssertionCount(assertions.len()));
}
let assertion = assertions[0];
let assertion_id = assertion
.attribute("ID")
.map(str::to_owned)
.ok_or_else(|| SamlError::MissingElement("Assertion/@ID".into()))?;
// 6. The signature must actually cover the assertion we're about to trust:
// either the assertion itself, the enclosing response, or the whole
// document. (bergshamra's strict_verification already constrains where
// the signed element may sit; this ties it to *our* assertion.)
let covers_assertion = verified_uris.iter().any(|u| {
u.is_empty()
|| u == &format!("#{assertion_id}")
|| response_id
.as_ref()
.is_some_and(|rid| u == &format!("#{rid}"))
});
if !covers_assertion {
return Err(SamlError::SignatureScope);
}
// 7. Issuer must be the configured IdP.
let issuer = first_child(assertion, "Issuer")
.map(text_of)
.unwrap_or_default();
if !idp.entity_id.is_empty() && issuer != idp.entity_id {
return Err(SamlError::IssuerMismatch);
}
// 8. Subject → NameID + SubjectConfirmationData time/recipient checks.
let subject = first_child(assertion, "Subject")
.ok_or_else(|| SamlError::MissingElement("Subject".into()))?;
let name_id = first_child(subject, "NameID")
.map(text_of)
.filter(|s| !s.is_empty())
.ok_or_else(|| SamlError::MissingElement("NameID".into()))?;
if let Some(scd) = subject
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "SubjectConfirmationData")
{
if let Some(recipient) = scd.attribute("Recipient") {
if !urls_equal(recipient, &sp.acs_url) {
return Err(SamlError::DestinationMismatch);
}
}
if let Some(noa) = scd.attribute("NotOnOrAfter") {
let noa = parse_instant(noa)?;
if now >= noa + clock_skew {
return Err(SamlError::TimeBounds);
}
}
}
// 9. Conditions: time window + audience.
let conditions = first_child(assertion, "Conditions");
if let Some(cond) = conditions {
if let Some(nb) = cond.attribute("NotBefore") {
let nb = parse_instant(nb)?;
if now < nb - clock_skew {
return Err(SamlError::TimeBounds);
}
}
}
let assertion_expiry = conditions
.and_then(|c| c.attribute("NotOnOrAfter"))
.map(parse_instant)
.transpose()?
.ok_or_else(|| SamlError::MissingElement("Conditions/@NotOnOrAfter".into()))?;
if now >= assertion_expiry + clock_skew {
return Err(SamlError::TimeBounds);
}
let audience_ok = conditions.is_some_and(|c| {
c.descendants()
.filter(|n| n.is_element() && n.tag_name().name() == "Audience")
.any(|a| text_of(a) == sp.entity_id)
});
if !audience_ok {
return Err(SamlError::AudienceMismatch);
}
// 10. Optional: SessionIndex + display-name attribute.
let session_index = assertion
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "AuthnStatement")
.and_then(|a| a.attribute("SessionIndex"))
.map(str::to_owned);
let display_name = extract_display_name(assertion);
Ok(VerifiedResponse {
principal: VerifiedPrincipal {
email: name_id.clone(),
name_id,
display_name,
},
in_response_to,
assertion_id,
assertion_expiry,
session_index,
})
}
/// Verify the document's XML-DSig against each pinned IdP cert in turn
/// (handles key rotation), returning the verified `<Reference>` URIs.
fn verify_signature(xml: &str, certs_der: &[Vec<u8>]) -> Result<Vec<String>, SamlError> {
let mut last_err = String::from("no signing certificate matched");
for der in certs_der {
let key = match bergshamra::keys::loader::load_x509_cert_der(der) {
Ok(k) => k,
Err(e) => {
last_err = e.to_string();
continue;
}
};
let mut km = bergshamra::keys::KeysManager::new();
km.add_key(key);
// trusted_keys_only: only ever trust the pinned IdP key, never an
// inline KeyInfo cert. strict_verification: XSW positional defense.
let ctx = bergshamra::DsigContext::new(km)
.with_trusted_keys_only(true)
.with_strict_verification(true);
match bergshamra::verify(&ctx, xml) {
Ok(bergshamra::VerifyResult::Valid { references, .. }) => {
return Ok(references.into_iter().map(|r| r.uri).collect());
}
Ok(bergshamra::VerifyResult::Invalid { reason }) => last_err = reason,
Err(e) => last_err = e.to_string(),
}
}
Err(SamlError::Signature(last_err))
}
/// Pull a display name from the assertion's attribute statement, trying the
/// common attribute names IdPs use (FleetDM checks the same set).
fn extract_display_name(assertion: Node<'_, '_>) -> Option<String> {
const WANTED: &[&str] = &[
"name",
"displayname",
"cn",
"urn:oid:2.5.4.3",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
];
for attr in assertion
.descendants()
.filter(|n| n.is_element() && n.tag_name().name() == "Attribute")
{
let key = attr
.attribute("Name")
.or_else(|| attr.attribute("FriendlyName"))
.unwrap_or("")
.to_ascii_lowercase();
if WANTED.contains(&key.as_str()) {
if let Some(val) = attr
.descendants()
.find(|n| n.is_element() && n.tag_name().name() == "AttributeValue")
{
let v = text_of(val);
if !v.is_empty() {
return Some(v);
}
}
}
}
None
}
fn first_child<'a, 'i>(n: Node<'a, 'i>, local: &str) -> Option<Node<'a, 'i>> {
n.children()
.find(|c| c.is_element() && c.tag_name().name() == local)
}
fn text_of(n: Node<'_, '_>) -> String {
n.children()
.filter(Node::is_text)
.filter_map(|c| c.text())
.collect::<String>()
.trim()
.to_owned()
}
/// Parse an `xs:dateTime` (e.g. `2026-05-31T12:00:00.000Z`).
fn parse_instant(s: &str) -> Result<OffsetDateTime, SamlError> {
OffsetDateTime::parse(s.trim(), &Rfc3339).map_err(|e| SamlError::Timestamp(format!("{s}: {e}")))
}
/// Compare two URLs for SAML endpoint-matching purposes: exact, or differing
/// only by a single trailing slash.
fn urls_equal(a: &str, b: &str) -> bool {
a == b || a.trim_end_matches('/') == b.trim_end_matches('/')
}
+287
View File
@@ -0,0 +1,287 @@
//! End-to-end SAML SP tests.
//!
//! We mint a throwaway self-signed cert/key with `rcgen`, sign a SAML Response
//! template with `bergshamra::sign` (the same engine that verifies it), and
//! drive [`response::consume`] through the accept path and every reject path.
//! This proves both the signature wiring and the SP-semantic checks.
use time::format_description::well_known::Rfc3339;
use time::{Duration, OffsetDateTime};
use super::metadata::IdpMetadata;
use super::{response, SamlError, SpParams};
const SP_ENTITY: &str = "https://pxe.example.com";
const ACS: &str = "https://pxe.example.com/api/sso/acs";
const IDP_ENTITY: &str = "https://idp.example.com/realms/fleet";
const EMAIL: &str = "[email protected]";
struct TestIdp {
cert_der: Vec<u8>,
key_pem: String,
}
fn test_idp() -> TestIdp {
let ck = rcgen::generate_simple_self_signed(vec!["idp.example.com".to_string()]).unwrap();
TestIdp {
cert_der: ck.cert.der().as_ref().to_vec(),
key_pem: ck.key_pair.serialize_pem(),
}
}
fn fmt(t: OffsetDateTime) -> String {
t.replace_nanosecond(0).unwrap().format(&Rfc3339).unwrap()
}
/// Knobs for building a response template — defaults are a valid response.
struct Resp {
issuer: String,
audience: String,
status: String,
not_before: OffsetDateTime,
not_on_or_after: OffsetDateTime,
in_response_to: Option<String>,
recipient: String,
}
impl Default for Resp {
fn default() -> Self {
let now = OffsetDateTime::now_utc();
Self {
issuer: IDP_ENTITY.into(),
audience: SP_ENTITY.into(),
status: "urn:oasis:names:tc:SAML:2.0:status:Success".into(),
not_before: now - Duration::minutes(5),
not_on_or_after: now + Duration::hours(1),
in_response_to: Some("_req-abc".into()),
recipient: ACS.into(),
}
}
}
impl Resp {
/// The unsigned template (a `<ds:Signature>` with empty values).
fn template(&self) -> String {
let now = fmt(OffsetDateTime::now_utc());
let irt = self
.in_response_to
.as_ref()
.map(|v| format!(r#" InResponseTo="{v}""#))
.unwrap_or_default();
format!(
r##"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_resp1" Version="2.0" IssueInstant="{now}" Destination="{ACS}"{irt}>
<saml:Issuer>{issuer}</saml:Issuer>
<samlp:Status><samlp:StatusCode Value="{status}"/></samlp:Status>
<saml:Assertion ID="_assertion1" Version="2.0" IssueInstant="{now}">
<saml:Issuer>{issuer}</saml:Issuer>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256"/>
<ds:Reference URI="#_assertion1">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<ds:DigestValue></ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue></ds:SignatureValue>
</ds:Signature>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">{EMAIL}</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData Recipient="{recipient}" NotOnOrAfter="{noa}"{irt}/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="{nb}" NotOnOrAfter="{noa}">
<saml:AudienceRestriction><saml:Audience>{audience}</saml:Audience></saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="{now}" SessionIndex="sess-123">
<saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef></saml:AuthnContext>
</saml:AuthnStatement>
<saml:AttributeStatement>
<saml:Attribute Name="displayName"><saml:AttributeValue>Miles Ward</saml:AttributeValue></saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
</samlp:Response>"##,
issuer = self.issuer,
status = self.status,
audience = self.audience,
recipient = self.recipient,
nb = fmt(self.not_before),
noa = fmt(self.not_on_or_after),
)
}
}
fn sign(template: &str, key_pem: &str) -> String {
let key = bergshamra::keys::loader::load_pem_auto(key_pem.as_bytes(), None)
.expect("load test signing key");
let mut km = bergshamra::keys::KeysManager::new();
km.add_key(key);
let ctx = bergshamra::DsigContext::new(km);
bergshamra::sign(&ctx, template).expect("sign test response")
}
fn sp() -> SpParams {
SpParams {
entity_id: SP_ENTITY.into(),
acs_url: ACS.into(),
}
}
fn idp(cert_der: Vec<u8>) -> IdpMetadata {
IdpMetadata {
entity_id: IDP_ENTITY.into(),
sso_redirect_url: None,
sso_post_url: None,
signing_certs_der: vec![cert_der],
}
}
fn consume(xml: &str, cert_der: Vec<u8>) -> Result<response::VerifiedResponse, SamlError> {
response::consume(
xml,
&sp(),
&idp(cert_der),
OffsetDateTime::now_utc(),
Duration::seconds(60),
)
}
#[test]
fn good_response_yields_principal() {
let t = test_idp();
let signed = sign(&Resp::default().template(), &t.key_pem);
let out = consume(&signed, t.cert_der).expect("valid response should verify");
assert_eq!(out.principal.email, EMAIL);
assert_eq!(out.principal.name_id, EMAIL);
assert_eq!(out.principal.display_name.as_deref(), Some("Miles Ward"));
assert_eq!(out.in_response_to.as_deref(), Some("_req-abc"));
assert_eq!(out.assertion_id, "_assertion1");
assert_eq!(out.session_index.as_deref(), Some("sess-123"));
}
#[test]
fn tampered_assertion_is_rejected() {
let t = test_idp();
let signed = sign(&Resp::default().template(), &t.key_pem);
// Flip the subject email after signing — breaks the digest.
let tampered = signed.replace(EMAIL, "[email protected]");
assert_ne!(signed, tampered);
assert!(matches!(
consume(&tampered, t.cert_der),
Err(SamlError::Signature(_) | SamlError::SignatureScope)
));
}
#[test]
fn unsigned_response_is_rejected() {
let t = test_idp();
// Feed the *unsigned* template (empty SignatureValue) straight in.
let unsigned = Resp::default().template();
assert!(matches!(
consume(&unsigned, t.cert_der),
Err(SamlError::Signature(_))
));
}
#[test]
fn wrong_signing_key_is_rejected() {
let signer = test_idp();
let other = test_idp(); // different keypair pinned as the "IdP" cert
let signed = sign(&Resp::default().template(), &signer.key_pem);
assert!(matches!(
consume(&signed, other.cert_der),
Err(SamlError::Signature(_))
));
}
#[test]
fn wrong_audience_is_rejected() {
let t = test_idp();
let r = Resp {
audience: "https://someone-else.example".into(),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::AudienceMismatch)
));
}
#[test]
fn expired_assertion_is_rejected() {
let t = test_idp();
let now = OffsetDateTime::now_utc();
let r = Resp {
not_before: now - Duration::hours(2),
not_on_or_after: now - Duration::hours(1),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::TimeBounds)
));
}
#[test]
fn future_assertion_is_rejected() {
let t = test_idp();
let now = OffsetDateTime::now_utc();
let r = Resp {
not_before: now + Duration::hours(1),
not_on_or_after: now + Duration::hours(2),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::TimeBounds)
));
}
#[test]
fn wrong_issuer_is_rejected() {
let t = test_idp();
let r = Resp {
issuer: "https://evil-idp.example".into(),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::IssuerMismatch)
));
}
#[test]
fn non_success_status_is_rejected() {
let t = test_idp();
let r = Resp {
status: "urn:oasis:names:tc:SAML:2.0:status:Requester".into(),
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
assert!(matches!(
consume(&signed, t.cert_der),
Err(SamlError::Status(_))
));
}
#[test]
fn idp_initiated_has_no_in_response_to() {
// No InResponseTo => the HTTP layer must gate it behind allow_idp_initiated.
let t = test_idp();
let r = Resp {
in_response_to: None,
..Resp::default()
};
let signed = sign(&r.template(), &t.key_pem);
let out = consume(&signed, t.cert_der).expect("unsolicited but otherwise valid");
assert!(out.in_response_to.is_none());
}
+22 -12
View File
@@ -47,13 +47,13 @@ pub struct Settings {
/// `timeout_action = LocalHdd`). /// `timeout_action = LocalHdd`).
pub default_local_hdd: bool, pub default_local_hdd: bool,
/// When a client hits the Gated Deployment item, how long (seconds) to /// When a client hits the Queued Deployment item, how long (seconds) to
/// hold it at the gate before giving up and falling back to the menu. /// hold it in queue before giving up and falling back to the menu.
/// 0 = forever. /// 0 = forever.
pub gate_wait_max_secs: u32, pub queue_wait_max_secs: u32,
/// Optional DNS server advertised on the Network tab. Purely /// Optional DNS server advertised on the Network tab. Purely
/// informational today — PXEForge does not run a DNS server, but /// informational today — OpenPXE does not run a DNS server, but
/// operators expect to be able to record what the upstream DNS is. /// operators expect to be able to record what the upstream DNS is.
/// Empty string = unset (UI shows placeholder). /// Empty string = unset (UI shows placeholder).
pub dns_server: String, pub dns_server: String,
@@ -66,21 +66,22 @@ pub enum TimeoutAction {
Stay, Stay,
/// Chain the "Boot from Local HDD" entry. /// Chain the "Boot from Local HDD" entry.
LocalHdd, LocalHdd,
/// Put the client into the gate queue, waiting for operator assignment. /// Put the client into the deployment queue, waiting for operator
/// assignment.
#[default] #[default]
GatedDeployment, QueuedDeployment,
} }
impl Default for Settings { impl Default for Settings {
fn default() -> Self { fn default() -> Self {
Self { Self {
boot_menu_timeout_secs: 600, boot_menu_timeout_secs: 600,
timeout_action: TimeoutAction::GatedDeployment, timeout_action: TimeoutAction::QueuedDeployment,
windows_enabled: false, windows_enabled: false,
smb_host_override: String::new(), smb_host_override: String::new(),
extra_kernel_args: String::new(), extra_kernel_args: String::new(),
default_local_hdd: true, default_local_hdd: true,
gate_wait_max_secs: 0, queue_wait_max_secs: 0,
dns_server: String::new(), dns_server: String::new(),
} }
} }
@@ -103,7 +104,7 @@ impl SettingsStore {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
tracing::warn!( tracing::warn!(
target: "pxeforge::settings", target: "openpxe::settings",
"settings.json present but unreadable ({e}); falling back to defaults" "settings.json present but unreadable ({e}); falling back to defaults"
); );
Settings::default() Settings::default()
@@ -111,7 +112,10 @@ impl SettingsStore {
}, },
Err(_) => Settings::default(), Err(_) => Settings::default(),
}; };
Arc::new(Self { path, inner: RwLock::new(initial) }) Arc::new(Self {
path,
inner: RwLock::new(initial),
})
} }
#[must_use] #[must_use]
@@ -130,7 +134,7 @@ impl SettingsStore {
} }
let snap = self.snapshot(); let snap = self.snapshot();
if let Err(e) = self.persist(&snap) { if let Err(e) = self.persist(&snap) {
tracing::warn!(target: "pxeforge::settings", "failed to persist settings: {e}"); tracing::warn!(target: "openpxe::settings", "failed to persist settings: {e}");
} }
} }
@@ -157,7 +161,7 @@ mod tests {
let store = SettingsStore::load_or_default(dir.path()); let store = SettingsStore::load_or_default(dir.path());
let s = store.snapshot(); let s = store.snapshot();
assert_eq!(s.boot_menu_timeout_secs, 600); assert_eq!(s.boot_menu_timeout_secs, 600);
assert_eq!(s.timeout_action, TimeoutAction::GatedDeployment); assert_eq!(s.timeout_action, TimeoutAction::QueuedDeployment);
assert!(!s.windows_enabled); assert!(!s.windows_enabled);
} }
@@ -177,6 +181,12 @@ mod tests {
assert!(s.windows_enabled); assert!(s.windows_enabled);
} }
#[test]
fn settings_serialize_queue_naming() {
let text = serde_json::to_string(&Settings::default()).unwrap();
assert!(text.contains("queue_wait_max_secs"));
}
#[test] #[test]
fn corrupt_file_falls_back_to_default() { fn corrupt_file_falls_back_to_default() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
+390
View File
@@ -0,0 +1,390 @@
//! SAML SSO configuration — FleetDM-shaped.
//!
//! The operator pastes their IdP's metadata XML (or its URL) and a
//! human-readable label. As of v0.5.1 the SAML login flow is wired
//! end-to-end (see [`crate::saml`]): SP-initiated AuthnRequest, the ACS
//! endpoint, pure-Rust signature verification, and operator-session
//! minting. This module owns only the persisted *configuration*.
//!
//! Shape borrowed from <https://github.com/fleetdm/fleet>'s app-config
//! SSO block, minus the user-RBAC fields (OpenPXE is single-tier: any
//! IdP-authenticated user the SP cryptographically verifies gets an
//! operator session; there is no per-user role table). Entity ID is
//! exposed (FleetDM-style) but defaults to the advertised public base
//! URL when blank, which is what most IdPs expect anyway.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use crate::{Error, Result};
/// The configurable surface. `metadata` and `metadata_url` are mutually
/// exclusive at apply time (one or the other identifies the IdP); the
/// store keeps both fields so an operator can switch between them
/// without losing the inactive one.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SsoConfig {
/// Master switch — when false, all SSO machinery (planned for a
/// later release) is skipped regardless of the rest of the fields.
#[serde(default)]
pub enabled: bool,
/// Display name shown on the WebUI's login screen as the "Sign in
/// with X" button label. Empty/whitespace falls back to "SSO".
#[serde(default)]
pub idp_name: String,
/// Optional HTTPS URL pointing at the IdP's brand logo. Rendered
/// next to `idp_name` on the WebUI's login screen (FleetDM-style).
/// Length-capped at [`MAX_URL_LEN`]; empty is fine.
#[serde(default)]
pub idp_logo_url: String,
/// Raw SAML metadata XML pasted by the operator. Mutually exclusive
/// with `metadata_url`; if both are set, the URL wins at apply time
/// (operators typically forget about a stale XML paste).
#[serde(default)]
pub metadata: String,
/// HTTPS URL where the IdP serves its metadata. Loaded lazily by the
/// future SAML flow; not validated here beyond a basic length cap.
#[serde(default)]
pub metadata_url: String,
/// SP Entity ID advertised to the IdP — mirrors FleetDM's "Entity ID".
/// Must exactly match the SP/Relying-Party entry configured on the IdP.
/// Empty falls back to the advertised public base URL at runtime, which
/// is what most IdPs expect. Length-capped at [`MAX_URL_LEN`].
#[serde(default)]
pub entity_id: String,
/// Allow IdP-initiated login — an unsolicited `<Response>` POSTed to the
/// ACS with no `InResponseTo`. Mirrors FleetDM's "Allow SSO login
/// initiated by identity provider". Default off; SP-initiated (the
/// "Sign in with X" button) is always allowed regardless.
#[serde(default)]
pub allow_idp_initiated: bool,
}
impl SsoConfig {
/// Returns `true` only when the config is *usable* — enabled, and
/// at least one of metadata/metadata_url is present. The future
/// login flow will key off this; for v0.4.5 the WebUI uses it to
/// surface a yellow "configured but not live yet" hint.
#[must_use]
pub fn is_usable(&self) -> bool {
self.enabled && (!self.metadata.trim().is_empty() || !self.metadata_url.trim().is_empty())
}
}
/// In-memory + on-disk SSO settings registry.
#[derive(Debug, Clone)]
pub struct SsoStore {
path: Arc<PathBuf>,
inner: Arc<RwLock<SsoConfig>>,
}
impl SsoStore {
/// Load from `<work_dir>/sso.json`, or start with the default empty
/// (`enabled = false`) config. A corrupt file falls back to default
/// rather than blocking startup.
#[must_use]
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("sso.json");
let cfg = match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str::<SsoConfig>(&text) {
Ok(parsed) => parsed,
Err(e) => {
tracing::warn!(
target: "openpxe::sso",
"sso.json present but unreadable ({e}); starting with default config"
);
SsoConfig::default()
}
},
Err(_) => SsoConfig::default(),
};
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(cfg)),
}
}
#[must_use]
pub fn snapshot(&self) -> SsoConfig {
self.inner.read().clone()
}
/// Replace the whole config in one shot. Light validation: metadata
/// XML and URL are length-capped so an operator can't OOM us by
/// pasting a 10 GiB blob; the IdP UI tab clamps the input visually,
/// but the server enforces a hard ceiling regardless.
pub fn replace(&self, mut cfg: SsoConfig) -> Result<SsoConfig> {
cfg.idp_name = cfg.idp_name.trim().to_string();
cfg.idp_logo_url = cfg.idp_logo_url.trim().to_string();
cfg.metadata = cfg.metadata.trim().to_string();
cfg.metadata_url = cfg.metadata_url.trim().to_string();
cfg.entity_id = cfg.entity_id.trim().to_string();
if cfg.entity_id.len() > MAX_URL_LEN {
return Err(Error::Invalid(format!(
"entity_id exceeds {MAX_URL_LEN}-char cap"
)));
}
if cfg.metadata.len() > MAX_METADATA_BYTES {
return Err(Error::Invalid(format!(
"metadata XML exceeds {MAX_METADATA_BYTES}-byte cap"
)));
}
if cfg.metadata_url.len() > MAX_URL_LEN {
return Err(Error::Invalid(format!(
"metadata_url exceeds {MAX_URL_LEN}-char cap"
)));
}
if cfg.idp_logo_url.len() > MAX_URL_LEN {
return Err(Error::Invalid(format!(
"idp_logo_url exceeds {MAX_URL_LEN}-char cap"
)));
}
if !cfg.metadata_url.is_empty()
&& !cfg.metadata_url.starts_with("http://")
&& !cfg.metadata_url.starts_with("https://")
{
return Err(Error::Invalid(
"metadata_url must start with http:// or https://".into(),
));
}
if !cfg.idp_logo_url.is_empty()
&& !cfg.idp_logo_url.starts_with("http://")
&& !cfg.idp_logo_url.starts_with("https://")
{
return Err(Error::Invalid(
"idp_logo_url must start with http:// or https://".into(),
));
}
// If they're trying to *enable* the integration but haven't
// supplied either source, reject — saves a "configured but
// unusable" surprise later.
if cfg.enabled && cfg.metadata.is_empty() && cfg.metadata_url.is_empty() {
return Err(Error::Invalid(
"enable SSO requires either metadata XML or a metadata URL".into(),
));
}
{
let mut g = self.inner.write();
*g = cfg.clone();
}
self.persist();
tracing::info!(
target: "openpxe::sso",
enabled = cfg.enabled,
idp = %cfg.idp_name,
has_xml = !cfg.metadata.is_empty(),
has_url = !cfg.metadata_url.is_empty(),
"sso configuration updated"
);
Ok(cfg)
}
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::sso", "serialize sso.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::sso", "write sso.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::warn!(target: "openpxe::sso", "rename sso.json: {e}");
}
}
}
/// Saturation caps. The numbers are generous for any real IdP metadata
/// document — Okta's largest is ~50 KB, Azure AD's ~30 KB.
const MAX_METADATA_BYTES: usize = 1024 * 1024;
const MAX_URL_LEN: usize = 2048;
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn default_is_disabled_and_empty() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let cfg = s.snapshot();
assert!(!cfg.enabled);
assert!(cfg.metadata.is_empty());
assert!(cfg.metadata_url.is_empty());
assert!(!cfg.is_usable());
}
#[test]
fn replace_metadata_url_round_trip_via_disk() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
s.replace(SsoConfig {
enabled: true,
idp_name: "Okta".into(),
metadata: String::new(),
metadata_url: "https://idp.example.com/metadata".into(),
idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
})
.unwrap();
drop(s);
let s2 = SsoStore::load_or_default(dir.path());
let cfg = s2.snapshot();
assert!(cfg.enabled);
assert!(cfg.is_usable());
assert_eq!(cfg.idp_name, "Okta");
assert_eq!(cfg.metadata_url, "https://idp.example.com/metadata");
}
#[test]
fn replace_xml_paste_is_accepted() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let xml = r#"<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata">test</EntityDescriptor>"#;
s.replace(SsoConfig {
enabled: true,
idp_name: "Test IdP".into(),
metadata: xml.into(),
metadata_url: String::new(),
idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
})
.unwrap();
assert!(s.snapshot().is_usable());
}
#[test]
fn enable_without_source_is_rejected() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let r = s.replace(SsoConfig {
enabled: true,
idp_name: "Okta".into(),
metadata: String::new(),
metadata_url: String::new(),
idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
});
assert!(matches!(r, Err(Error::Invalid(_))));
// …and a disabled blank config is fine.
s.replace(SsoConfig::default()).unwrap();
}
#[test]
fn metadata_url_must_be_http_scheme() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let r = s.replace(SsoConfig {
enabled: false,
idp_name: String::new(),
metadata: String::new(),
metadata_url: "ftp://idp.example.com/metadata".into(),
idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
});
assert!(matches!(r, Err(Error::Invalid(_))));
}
#[test]
fn idp_logo_url_must_be_http_scheme() {
// v0.4.6: SSO settings learned an idp_logo_url so the login
// screen can render the FleetDM-style "Sign in with <IdP-logo>"
// affordance. Same scheme rule as metadata_url.
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let r = s.replace(SsoConfig {
enabled: false,
idp_name: "Okta".into(),
metadata: String::new(),
metadata_url: String::new(),
idp_logo_url: "data:image/png;base64,...".into(),
entity_id: String::new(),
allow_idp_initiated: false,
});
assert!(matches!(r, Err(Error::Invalid(_))));
// Real HTTPS URL is fine.
s.replace(SsoConfig {
enabled: false,
idp_name: "Okta".into(),
metadata: String::new(),
metadata_url: String::new(),
idp_logo_url: "https://idp.example.com/logo.png".into(),
entity_id: String::new(),
allow_idp_initiated: false,
})
.unwrap();
assert_eq!(
s.snapshot().idp_logo_url,
"https://idp.example.com/logo.png"
);
}
#[test]
fn entity_id_and_idp_initiated_round_trip() {
// v0.5.1: SP Entity ID + IdP-initiated toggle persist across reload.
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
s.replace(SsoConfig {
enabled: true,
idp_name: "Keycloak".into(),
metadata: String::new(),
metadata_url: "https://idp.example.com/metadata".into(),
idp_logo_url: String::new(),
entity_id: "https://pxe.example.com".into(),
allow_idp_initiated: true,
})
.unwrap();
drop(s);
let cfg = SsoStore::load_or_default(dir.path()).snapshot();
assert_eq!(cfg.entity_id, "https://pxe.example.com");
assert!(cfg.allow_idp_initiated);
}
#[test]
fn entity_id_cap_enforced() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let r = s.replace(SsoConfig {
enabled: false,
idp_name: String::new(),
metadata: String::new(),
metadata_url: String::new(),
idp_logo_url: String::new(),
entity_id: "x".repeat(MAX_URL_LEN + 1),
allow_idp_initiated: false,
});
assert!(matches!(r, Err(Error::Invalid(_))));
}
#[test]
fn metadata_size_cap_enforced() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let oversize = "a".repeat(MAX_METADATA_BYTES + 1);
let r = s.replace(SsoConfig {
enabled: false,
idp_name: String::new(),
metadata: oversize,
metadata_url: String::new(),
idp_logo_url: String::new(),
entity_id: String::new(),
allow_idp_initiated: false,
});
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[..]);
}
}
+3 -3
View File
@@ -1,16 +1,16 @@
[package] [package]
name = "pxeforge-dhcp-proxy" name = "openpxe-dhcp-proxy"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
description = "DHCP proxy (RFC 4578) for PXEForge — serves boot info, does not lease IPs" description = "DHCP proxy (RFC 4578) for OpenPXE — serves boot info, does not lease IPs"
[lints] [lints]
workspace = true workspace = true
[dependencies] [dependencies]
pxeforge-core.workspace = true openpxe-core.workspace = true
tokio.workspace = true tokio.workspace = true
socket2.workspace = true socket2.workspace = true
dhcproto.workspace = true dhcproto.workspace = true
+23 -6
View File
@@ -9,7 +9,7 @@
//! pass, or the HTTP URL of the boot script once iPXE has chained. //! pass, or the HTTP URL of the boot script once iPXE has chained.
use dhcproto::v4::{DhcpOption, Message, MessageType, Opcode, OptionCode}; use dhcproto::v4::{DhcpOption, Message, MessageType, Opcode, OptionCode};
use pxeforge_core::{ClientArch, FirmwareClass}; use openpxe_core::{ClientArch, FirmwareClass};
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
/// Where the reply directs the client next. /// Where the reply directs the client next.
@@ -41,7 +41,14 @@ pub struct ReplyContext<'a> {
pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective { pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
match ctx.class { match ctx.class {
FirmwareClass::IpxeUserClass => BootDirective::HttpScript { FirmwareClass::IpxeUserClass => BootDirective::HttpScript {
url: format!("{}/boot.ipxe", ctx.public_base_url.trim_end_matches('/')), // Pass the client's MAC in the query string so the HTTP
// layer can short-circuit to a per-MAC binding when one
// exists. iPXE substitutes `${mac}` literally before issuing
// the GET, so this stays static across firmwares.
url: format!(
"{}/boot.ipxe?mac=${{mac}}",
ctx.public_base_url.trim_end_matches('/')
),
}, },
FirmwareClass::HttpClient => { FirmwareClass::HttpClient => {
// UEFI HTTP boot: client wants an http:// URL in option 67 // UEFI HTTP boot: client wants an http:// URL in option 67
@@ -49,11 +56,17 @@ pub fn decide(ctx: &ReplyContext<'_>) -> BootDirective {
// it'll then do the same script-fetch the iPXE path does. // it'll then do the same script-fetch the iPXE path does.
let name = ctx.arch.ipxe_bootfile().unwrap_or("snponly.efi"); let name = ctx.arch.ipxe_bootfile().unwrap_or("snponly.efi");
BootDirective::HttpScript { BootDirective::HttpScript {
url: format!("{}/ipxe/{}", ctx.public_base_url.trim_end_matches('/'), name), url: format!(
"{}/ipxe/{}",
ctx.public_base_url.trim_end_matches('/'),
name
),
} }
} }
FirmwareClass::PxeClient => match ctx.arch.ipxe_bootfile() { FirmwareClass::PxeClient => match ctx.arch.ipxe_bootfile() {
Some(name) => BootDirective::TftpIpxe { filename: name.to_string() }, Some(name) => BootDirective::TftpIpxe {
filename: name.to_string(),
},
None => BootDirective::Ignore, None => BootDirective::Ignore,
}, },
FirmwareClass::Other => BootDirective::Ignore, FirmwareClass::Other => BootDirective::Ignore,
@@ -100,12 +113,16 @@ pub fn build_reply(ctx: &ReplyContext<'_>, directive: &BootDirective) -> Option<
match directive { match directive {
BootDirective::TftpIpxe { filename } => { BootDirective::TftpIpxe { filename } => {
opts.insert(DhcpOption::TFTPServerName(ctx.our_ip.to_string().into_bytes())); opts.insert(DhcpOption::TFTPServerName(
ctx.our_ip.to_string().into_bytes(),
));
opts.insert(DhcpOption::BootfileName(filename.as_bytes().to_vec())); opts.insert(DhcpOption::BootfileName(filename.as_bytes().to_vec()));
} }
BootDirective::HttpScript { url } => { BootDirective::HttpScript { url } => {
opts.insert(DhcpOption::BootfileName(url.as_bytes().to_vec())); opts.insert(DhcpOption::BootfileName(url.as_bytes().to_vec()));
opts.insert(DhcpOption::TFTPServerName(ctx.our_ip.to_string().into_bytes())); opts.insert(DhcpOption::TFTPServerName(
ctx.our_ip.to_string().into_bytes(),
));
} }
BootDirective::Ignore => return None, BootDirective::Ignore => return None,
} }
+50 -17
View File
@@ -4,9 +4,7 @@
use crate::reply::{build_reply, decide, BootDirective, ReplyContext}; use crate::reply::{build_reply, decide, BootDirective, ReplyContext};
use dhcproto::v4::{DhcpOption, Message, OptionCode}; use dhcproto::v4::{DhcpOption, Message, OptionCode};
use dhcproto::{Decodable, Decoder, Encodable, Encoder}; use dhcproto::{Decodable, Decoder, Encodable, Encoder};
use pxeforge_core::{ use openpxe_core::{ClientArch, ClientEvent, ClientRegistry, FirmwareClass};
ClientArch, ClientEvent, ClientRegistry, FirmwareClass,
};
use socket2::{Domain, Protocol, Socket, Type}; use socket2::{Domain, Protocol, Socket, Type};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use std::sync::Arc; use std::sync::Arc;
@@ -19,6 +17,7 @@ pub struct DhcpProxyServer {
our_ip: Ipv4Addr, our_ip: Ipv4Addr,
public_base_url: String, public_base_url: String,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
} }
impl DhcpProxyServer { impl DhcpProxyServer {
@@ -29,15 +28,24 @@ impl DhcpProxyServer {
our_ip: Ipv4Addr, our_ip: Ipv4Addr,
public_base_url: String, public_base_url: String,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
) -> Self { ) -> Self {
Self { bind, dhcp_port, pxe_port, our_ip, public_base_url, clients } Self {
bind,
dhcp_port,
pxe_port,
our_ip,
public_base_url,
clients,
metrics,
}
} }
pub async fn run(self) -> anyhow::Result<()> { pub async fn run(self) -> anyhow::Result<()> {
let dhcp_sock = bind_udp(self.bind, self.dhcp_port, true)?; let dhcp_sock = bind_udp(self.bind, self.dhcp_port, true)?;
let pxe_sock = bind_udp(self.bind, self.pxe_port, false)?; let pxe_sock = bind_udp(self.bind, self.pxe_port, false)?;
tracing::info!( tracing::info!(
target: "pxeforge::dhcp", target: "openpxe::dhcp",
"DHCP proxy listening on {}:{} and :{}", "DHCP proxy listening on {}:{} and :{}",
self.bind, self.dhcp_port, self.pxe_port self.bind, self.dhcp_port, self.pxe_port
); );
@@ -57,12 +65,12 @@ impl DhcpProxyServer {
let (n, from) = match sock.recv_from(&mut buf).await { let (n, from) = match sock.recv_from(&mut buf).await {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
tracing::warn!(target: "pxeforge::dhcp", port=label, "recv error: {e}"); tracing::warn!(target: "openpxe::dhcp", port=label, "recv error: {e}");
continue; continue;
} }
}; };
if let Err(e) = self.handle_datagram(&sock, &buf[..n], from, label).await { if let Err(e) = self.handle_datagram(&sock, &buf[..n], from, label).await {
tracing::warn!(target: "pxeforge::dhcp", port=label, "handle error: {e}"); tracing::warn!(target: "openpxe::dhcp", port=label, "handle error: {e}");
} }
} }
} }
@@ -76,11 +84,22 @@ impl DhcpProxyServer {
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let request = Message::decode(&mut Decoder::new(data))?; let request = Message::decode(&mut Decoder::new(data))?;
let vendor_class = request.opts().get(OptionCode::ClassIdentifier).and_then(|o| { let vendor_class = request
if let DhcpOption::ClassIdentifier(v) = o { Some(v.as_slice()) } else { None } .opts()
.get(OptionCode::ClassIdentifier)
.and_then(|o| {
if let DhcpOption::ClassIdentifier(v) = o {
Some(v.as_slice())
} else {
None
}
}); });
let user_class = request.opts().get(OptionCode::UserClass).and_then(|o| { let user_class = request.opts().get(OptionCode::UserClass).and_then(|o| {
if let DhcpOption::UserClass(v) = o { Some(v.as_slice()) } else { None } if let DhcpOption::UserClass(v) = o {
Some(v.as_slice())
} else {
None
}
}); });
let class = FirmwareClass::classify(vendor_class, user_class); let class = FirmwareClass::classify(vendor_class, user_class);
if matches!(class, FirmwareClass::Other) { if matches!(class, FirmwareClass::Other) {
@@ -116,21 +135,25 @@ impl DhcpProxyServer {
}; };
let directive = decide(&ctx); let directive = decide(&ctx);
if matches!(directive, BootDirective::Ignore) { if matches!(directive, BootDirective::Ignore) {
self.metrics.record_dhcp_decline();
tracing::debug!( tracing::debug!(
target: "pxeforge::dhcp", target: "openpxe::dhcp",
mac=%mac, arch=?arch, "ignoring — no bootfile for arch" mac=%mac, arch=?arch, "ignoring — no bootfile for arch"
); );
return Ok(()); return Ok(());
} }
self.metrics.record_dhcp_reply(arch.as_str());
let Some(reply) = build_reply(&ctx, &directive) else { return Ok(()); }; let Some(reply) = build_reply(&ctx, &directive) else {
return Ok(());
};
let mut out = Vec::with_capacity(512); let mut out = Vec::with_capacity(512);
reply.encode(&mut Encoder::new(&mut out))?; reply.encode(&mut Encoder::new(&mut out))?;
let dest = reply_destination(&request, from); let dest = reply_destination(&request, from);
sock.send_to(&out, dest).await?; sock.send_to(&out, dest).await?;
tracing::info!( tracing::info!(
target: "pxeforge::dhcp", target: "openpxe::dhcp",
mac=%mac, arch=arch.as_str(), class=?class, dest=%dest, directive=?directive, mac=%mac, arch=arch.as_str(), class=?class, dest=%dest, directive=?directive,
"PXE reply sent" "PXE reply sent"
); );
@@ -191,7 +214,10 @@ fn bind_udp(bind: IpAddr, port: u16, broadcast: bool) -> anyhow::Result<UdpSocke
fn format_mac(chaddr: &[u8]) -> String { fn format_mac(chaddr: &[u8]) -> String {
let take = chaddr.iter().take(6).copied().collect::<Vec<_>>(); let take = chaddr.iter().take(6).copied().collect::<Vec<_>>();
take.iter().map(|b| format!("{b:02x}")).collect::<Vec<_>>().join(":") take.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(":")
} }
/// Walk raw DHCP options looking for option 93 (Client System Architecture) /// Walk raw DHCP options looking for option 93 (Client System Architecture)
@@ -205,10 +231,17 @@ fn extract_raw_arch(packet: &[u8]) -> Option<u16> {
let mut i = 0; let mut i = 0;
while i < opts.len() { while i < opts.len() {
let code = opts[i]; let code = opts[i];
if code == 0xff { return None; } // END if code == 0xff {
if code == 0x00 { i += 1; continue; } // PAD return None;
} // END
if code == 0x00 {
i += 1; i += 1;
if i >= opts.len() { return None; } continue;
} // PAD
i += 1;
if i >= opts.len() {
return None;
}
let len = opts[i] as usize; let len = opts[i] as usize;
i += 1; i += 1;
if code == 93 && len >= 2 && i + 2 <= opts.len() { if code == 93 && len >= 2 && i + 2 <= opts.len() {
+28 -5
View File
@@ -1,19 +1,23 @@
[package] [package]
name = "pxeforge-http-api" name = "openpxe-http-api"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
# v0.5.0: inherit the workspace repository so CARGO_PKG_REPOSITORY is
# populated at build time — the About-tab update check derives the
# Gitea releases API URL from it.
repository.workspace = true
description = "HTTP server: ISO uploads, iPXE script generation, ISO streaming" description = "HTTP server: ISO uploads, iPXE script generation, ISO streaming"
[lints] [lints]
workspace = true workspace = true
[dependencies] [dependencies]
pxeforge-core.workspace = true openpxe-core.workspace = true
pxeforge-iso-store.workspace = true openpxe-iso-store.workspace = true
pxeforge-ipxe-assets.workspace = true openpxe-ipxe-assets.workspace = true
pxeforge-webui.workspace = true openpxe-webui.workspace = true
tokio.workspace = true tokio.workspace = true
tokio-util.workspace = true tokio-util.workspace = true
tokio-stream.workspace = true tokio-stream.workspace = true
@@ -31,6 +35,17 @@ bytes.workspace = true
futures.workspace = true futures.workspace = true
mime.workspace = true mime.workspace = true
mime_guess.workspace = true 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
# v0.5.1: decode the base64 SAMLResponse at the ACS endpoint.
base64.workspace = true
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] } tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] }
@@ -38,3 +53,11 @@ tower = { workspace = true }
tempfile = "3.12" tempfile = "3.12"
serde_json = { workspace = true } serde_json = { workspace = true }
time = { workspace = true } time = { workspace = true }
# v0.4.61: integration tests need to generate real PNG bytes for the
# `/branding/pxe-logo` compositor; hand-rolled CRCs are too fragile.
image = { version = "0.25", default-features = false, features = ["png"] }
# v0.5.1: the SAML ACS integration tests mint a throwaway IdP keypair
# (rcgen) and sign a SAMLResponse with bergshamra so the happy-path,
# replay, and IdP-initiated-gating flows exercise real signatures.
rcgen = "0.13"
bergshamra = { workspace = true }
+2343 -152
View File
File diff suppressed because it is too large Load Diff
+516
View File
@@ -0,0 +1,516 @@
//! Forms auth layer — sessions, login, setup, middleware.
//!
//! Three states:
//!
//! * **Unconfigured** (`AdminStore::is_configured() == false`). The
//! middleware passes every request through — there's no one to gate
//! against. The UI's `/api/me` returns `setup_required: true` and the
//! front-end pushes the operator into the first-run flow.
//! * **Logged in**. The session cookie maps to an in-memory session
//! record with an idle expiry; `/api/me` returns the username.
//! * **Logged out**. The middleware bounces `/api/*` (with the PXE
//! allowlist below) to `401 Unauthorized`; the front-end intercepts
//! that and shows `/login`.
//!
//! Allowlist for unauthenticated access *after* the admin is set up:
//!
//! * everything outside `/api/*` (the WebUI bundle, asset chrome, PXE
//! script endpoints, the bundled iPXE/wimboot binaries, ISO bytes,
//! liveness/readiness probes, the Prometheus scrape) — these are
//! read-only or PXE-essential and breaking them locks out booting
//! machines that have no way to authenticate;
//! * `/api/setup`, `/api/login`, `/api/me` (the auth surface itself);
//! * `/api/queue/join`, `/api/queue/poll/:entry_id` (iPXE long-poll for
//! Queued Deployment — the iPXE client can't send a session cookie).
//!
//! Everything else inside `/api/*` requires a valid session.
use crate::state::AppState;
use axum::{
body::Body,
extract::{Request, State},
http::{header, HeaderValue, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Json,
};
use openpxe_core::AdminPublic;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use uuid::Uuid;
/// Idle session lifetime. Sliding — every authenticated request resets
/// the expiry. 24h is the Sonarr default and matches what most operators
/// expect for an on-prem admin console.
const SESSION_TTL: Duration = Duration::from_hours(24);
/// Name of the cookie we set/read. Distinct from a generic `session=`
/// to avoid collisions with anything else sharing the host.
pub const SESSION_COOKIE: &str = "openpxe_session";
#[derive(Debug, Clone)]
struct Session {
username: String,
expires_at: Instant,
}
/// In-memory session table. Cheap to clone (Arc-shared) and contention
/// is rare — operators sign in once per browser session.
#[derive(Debug, Clone, Default)]
pub struct SessionStore {
inner: Arc<RwLock<HashMap<String, Session>>>,
}
impl SessionStore {
/// Mint a fresh session for `username` and return the opaque cookie
/// value. UUID v4 gives us 122 random bits — comfortably more than
/// the 64-128 bits typical for session IDs.
#[must_use]
pub fn create(&self, username: &str) -> String {
let id = Uuid::new_v4().simple().to_string();
let session = Session {
username: username.to_string(),
expires_at: Instant::now() + SESSION_TTL,
};
self.inner.write().insert(id.clone(), session);
id
}
/// Resolve a cookie value to the owning username, refreshing the
/// idle timer. Returns `None` for missing / expired sessions and
/// proactively evicts the expired entry so the map doesn't grow
/// unbounded across long-lived deployments.
pub fn touch(&self, id: &str) -> Option<String> {
let mut g = self.inner.write();
let s = g.get_mut(id)?;
if s.expires_at <= Instant::now() {
g.remove(id);
return None;
}
s.expires_at = Instant::now() + SESSION_TTL;
Some(s.username.clone())
}
/// Invalidate one session (the user's `/api/logout`).
pub fn revoke(&self, id: &str) {
self.inner.write().remove(id);
}
/// Invalidate every session — used after a credentials rotation so
/// stale cookies for the old password can't keep operating.
pub fn revoke_all(&self) {
self.inner.write().clear();
}
/// Periodic / opportunistic GC. Not currently scheduled (we evict
/// on touch), but exposed for a future janitor task.
pub fn gc(&self) {
let now = Instant::now();
self.inner.write().retain(|_, s| s.expires_at > now);
}
#[must_use]
pub fn len(&self) -> usize {
self.inner.read().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
// ── Cookie helpers ────────────────────────────────────────────────────────
fn cookie_attrs(value: &str, max_age: Option<i64>) -> String {
// Same flags FleetDM and Sonarr ship by default:
// - HttpOnly: blocks JS access (XSS containment)
// - SameSite=Lax: allows top-level GET navigations from the IdP
// to land authenticated when SSO arrives, but blocks
// cross-site POST CSRF;
// - Path=/: the cookie applies to the whole app;
// - no Secure flag yet — many operators host on plain http://
// LAN IPs (Unraid templates default to that); we'll add Secure
// opportunistically when we add a TLS terminator option.
// SESSION_TTL fits in 32 bits comfortably (24h ≈ 86400 seconds); we
// never overflow i64, but clippy's `cast_possible_wrap` lint wants
// us to be explicit. `cast_signed` is the documented form.
let lifetime = max_age.unwrap_or_else(|| SESSION_TTL.as_secs().cast_signed());
format!("{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={lifetime}")
}
/// Build the `Set-Cookie` header value that establishes a fresh operator
/// session with the default 24h TTL. Exposed so the SAML ACS handler can
/// attach an operator session to its post-login redirect, exactly as the
/// Forms-login path does via [`login_response`].
#[must_use]
pub fn session_cookie(session: &str) -> String {
cookie_attrs(session, None)
}
fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
// `Cookie: a=b; c=d` parsing — small enough not to drag in a crate.
let raw = headers.get(header::COOKIE)?.to_str().ok()?;
for part in raw.split(';') {
let part = part.trim();
if let Some(v) = part.strip_prefix(&format!("{SESSION_COOKIE}=")) {
return Some(v.to_string());
}
}
None
}
// ── Middleware ────────────────────────────────────────────────────────────
/// Return `true` if `path` is on the allowlist and should bypass the
/// session check. The middleware applies this rule only when the admin
/// account is configured; before then everything is open.
fn is_public_path(path: &str) -> bool {
// Non-API paths: WebUI bundle, PXE chain, ISO bytes, health probes,
// metrics. All read-only / PXE-essential.
if !path.starts_with("/api/") {
return true;
}
// Auth surface and iPXE long-poll endpoints (no cookie available).
// The SAML SP endpoints are pre-auth by nature — the operator hasn't a
// session yet when they start (or arrive from) the IdP. `/api/sso`
// (the config GET/PUT, no trailing slash) stays gated.
matches!(
path,
"/api/setup"
| "/api/login"
| "/api/logout"
| "/api/me"
| "/api/sso/login"
| "/api/sso/acs"
| "/api/sso/metadata"
) || path.starts_with("/api/queue/join")
|| path.starts_with("/api/queue/poll/")
}
/// Axum middleware: gate `/api/*` behind a valid session, with the
/// allowlist above. `State<AppState>` reaches in for the admin store +
/// session store.
pub async fn require_auth(
State(state): State<AppState>,
req: Request<Body>,
next: Next,
) -> Response {
// Bypass entirely while unconfigured. The /api/setup endpoint is
// the only one that can flip this back to "configured", and it
// refuses to run a second time. Tests + fresh installs ride this
// path.
if !state.admin.is_configured() {
return next.run(req).await;
}
let path = req.uri().path();
if is_public_path(path) {
return next.run(req).await;
}
// Authenticated path. The cookie must be present, map to a live
// session, and the TTL refresh happens as a side-effect.
let token = parse_cookie(req.headers());
if let Some(t) = token {
if state.sessions.touch(&t).is_some() {
return next.run(req).await;
}
}
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "authentication required" })),
)
.into_response()
}
// ── Handlers ──────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct SetupBody {
pub username: String,
pub password: String,
}
/// First-run setup. Refuses to run once an admin already exists — that
/// guards against a leaked WebUI being re-bootstrapped by an attacker
/// who's seen the deployment URL. After bootstrap, the new session
/// cookie is set so the operator goes straight to the dashboard.
pub async fn api_setup(State(state): State<AppState>, Json(body): Json<SetupBody>) -> Response {
if state.admin.is_configured() {
return (
StatusCode::CONFLICT,
Json(json!({ "error": "admin account already configured" })),
)
.into_response();
}
match state.admin.bootstrap(&body.username, &body.password) {
Ok(pub_) => {
let session = state.sessions.create(&pub_.username);
login_response(StatusCode::CREATED, &pub_, &session)
}
Err(openpxe_core::Error::Invalid(msg)) => {
(StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("{e}") })),
)
.into_response(),
}
}
#[derive(Debug, Deserialize)]
pub struct LoginBody {
pub username: String,
pub password: String,
}
pub async fn api_login(State(state): State<AppState>, Json(body): Json<LoginBody>) -> Response {
// Brief, deliberately vague — "invalid credentials" rather than
// "no such user" / "wrong password". Same anti-enumeration posture
// as Sonarr/Radarr.
let pub_ = match state.admin.verify(&body.username, &body.password) {
Ok(Some(u)) => u,
Ok(None) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "invalid username or password" })),
)
.into_response();
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("{e}") })),
)
.into_response();
}
};
let session = state.sessions.create(&pub_.username);
login_response(StatusCode::OK, &pub_, &session)
}
pub async fn api_logout(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
if let Some(t) = parse_cookie(&headers) {
state.sessions.revoke(&t);
}
// Stomp the cookie unconditionally — even if the request didn't
// carry one, the browser shouldn't keep a stale value.
let mut resp = StatusCode::NO_CONTENT.into_response();
resp.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_str(&cookie_attrs("", Some(0))).unwrap(),
);
resp
}
/// Status surface for the front-end shell. Returns four cases:
///
/// * `setup_required: true` — no admin yet; show first-run page.
/// * `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_any_web_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();
}
let token = parse_cookie(&headers);
let username = token.as_deref().and_then(|t| state.sessions.touch(t));
match username {
Some(u) => (
StatusCode::OK,
Json(json!({
"setup_required": false,
"authenticated": true,
"user": state.admin.snapshot(),
"session_user": u,
"has_custom_logo": has_custom_logo,
"logo_rev": logo_rev,
})),
)
.into_response(),
None => (
StatusCode::OK,
Json(json!({
"setup_required": false,
"authenticated": false,
"has_custom_logo": has_custom_logo,
"logo_rev": logo_rev,
})),
)
.into_response(),
}
}
#[derive(Debug, Deserialize)]
pub struct UpdateCredentialsBody {
pub current_password: String,
#[serde(default)]
pub new_username: Option<String>,
#[serde(default)]
pub new_password: Option<String>,
}
/// Rotate the admin's username and/or password. Auth middleware has
/// already proved the caller owns a session; we additionally require
/// the *current* password to prove "person at the keyboard right now".
/// On success we issue a fresh session cookie keyed to the (possibly
/// new) username and revoke every prior session so a stolen cookie
/// from before the rotation stops working.
pub async fn api_update_credentials(
State(state): State<AppState>,
Json(body): Json<UpdateCredentialsBody>,
) -> Response {
if !state.admin.is_configured() {
return (
StatusCode::CONFLICT,
Json(json!({ "error": "no admin configured" })),
)
.into_response();
}
let result = state.admin.update_credentials(
&body.current_password,
body.new_username.as_deref(),
body.new_password.as_deref(),
);
match result {
Ok(pub_) => {
state.sessions.revoke_all();
let session = state.sessions.create(&pub_.username);
login_response(StatusCode::OK, &pub_, &session)
}
Err(openpxe_core::Error::Invalid(msg)) => {
(StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("{e}") })),
)
.into_response(),
}
}
#[derive(Debug, Serialize)]
struct LoginPayload<'a> {
user: &'a AdminPublic,
authenticated: bool,
}
fn login_response(status: StatusCode, user: &AdminPublic, session: &str) -> Response {
let body = Json(LoginPayload {
user,
authenticated: true,
});
let mut resp = (status, body).into_response();
resp.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_str(&cookie_attrs(session, None)).unwrap(),
);
resp
}
// ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_create_touch_revoke() {
let s = SessionStore::default();
assert!(s.is_empty());
let t = s.create("admin");
assert_eq!(s.len(), 1);
assert_eq!(s.touch(&t).as_deref(), Some("admin"));
s.revoke(&t);
assert!(s.is_empty());
// Stale token doesn't error, just returns None.
assert!(s.touch(&t).is_none());
}
#[test]
fn session_revoke_all_clears() {
let s = SessionStore::default();
let _ = s.create("a");
let _ = s.create("b");
assert_eq!(s.len(), 2);
s.revoke_all();
assert!(s.is_empty());
}
#[test]
fn public_path_allowlist() {
// PXE + chrome paths bypass auth.
for p in [
"/",
"/assets/app.js",
"/boot.ipxe",
"/boot/fake.ipxe",
"/iso/fake.iso",
"/ipxe/snponly.efi",
"/healthz",
"/readyz",
"/metrics",
// v0.4.6: iPXE fetches this for `console --picture` before
// it can possibly have a session cookie.
"/branding/pxe-logo",
] {
assert!(is_public_path(p), "expected {p} to be public");
}
// Auth surface itself is public.
for p in ["/api/setup", "/api/login", "/api/logout", "/api/me"] {
assert!(is_public_path(p), "expected {p} to be public");
}
// v0.5.1: SAML SP endpoints are pre-auth (no session yet).
for p in ["/api/sso/login", "/api/sso/acs", "/api/sso/metadata"] {
assert!(is_public_path(p), "expected {p} to be public");
}
// iPXE long-poll endpoints are public (no cookie available).
assert!(is_public_path("/api/queue/join"));
assert!(is_public_path("/api/queue/poll/abc"));
// Everything else under /api/* must auth.
for p in [
"/api/isos",
"/api/isos/x/category",
"/api/storage/disk",
"/api/branding/logo",
"/api/sso",
"/api/hosts",
] {
assert!(!is_public_path(p), "expected {p} to require auth");
}
}
#[test]
fn cookie_parse_picks_session_value() {
let mut h = axum::http::HeaderMap::new();
h.insert(
header::COOKIE,
HeaderValue::from_str(&format!("foo=bar; {SESSION_COOKIE}=abc123; baz=qux")).unwrap(),
);
assert_eq!(parse_cookie(&h).as_deref(), Some("abc123"));
// Different name → None.
let mut h2 = axum::http::HeaderMap::new();
h2.insert(header::COOKIE, HeaderValue::from_str("foo=bar").unwrap());
assert!(parse_cookie(&h2).is_none());
// No cookie header at all → None.
assert!(parse_cookie(&axum::http::HeaderMap::new()).is_none());
}
}
+452 -61
View File
@@ -8,12 +8,12 @@
//! > Boot from Local HDD //! > Boot from Local HDD
//! Installers //! Installers
//! > Linux Installers -> submenu of Linux ISOs //! > Linux Installers -> submenu of Linux ISOs
//! > Windows Installers -> submenu of Windows ISOs (gated by Settings::windows_enabled) //! > Windows Installers -> submenu of Windows ISOs (controlled by Settings::windows_enabled)
//! Tools //! Tools
//! > Utilities -> memtest, etc. (embedded assets only) //! > Utilities -> memtest, etc. (embedded assets only)
//! > PXEForge Shell -> drop to iPXE shell with branded prompt //! > OpenPXE Shell -> drop to iPXE shell with branded prompt
//! > Network Card Info -> ifstat / config / route dump //! > Network Card Info -> ifstat / config / route dump
//! Gated Deployment -> join the gate queue //! Queued Deployment -> join the deployment queue
//! ``` //! ```
//! //!
//! ## iPXE is entirely backend — users do not see or write iPXE //! ## iPXE is entirely backend — users do not see or write iPXE
@@ -22,36 +22,80 @@
//! those knobs into iPXE primitives (chain, menu, item, choose, etc.). //! those knobs into iPXE primitives (chain, menu, item, choose, etc.).
//! There is intentionally no UI path to upload a custom `.ipxe` script. //! There is intentionally no UI path to upload a custom `.ipxe` script.
use pxeforge_core::{Settings, TimeoutAction}; use openpxe_core::{Settings, TimeoutAction};
use pxeforge_iso_store::{BootEntry, BootKind, IsoMeta}; use openpxe_iso_store::introspect::DistroFamily;
use pxeforge_iso_store::introspect::DistroFamily; use openpxe_iso_store::{BootEntry, BootKind, IsoMeta};
use std::fmt::Write as _; use std::fmt::Write as _;
/// Top-level PXEForge boot menu. Serialized identically for BIOS and UEFI /// Top-level OpenPXE boot menu. Serialized identically for BIOS and UEFI
/// clients because iPXE normalises the menu primitives across firmwares. /// clients because iPXE normalises the menu primitives across firmwares.
///
/// v0.4.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] #[must_use]
pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String { pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String {
let mut s = String::new(); let mut s = String::new();
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
let timeout_ms = settings.boot_menu_timeout_secs.saturating_mul(1000); let timeout_ms = settings.boot_menu_timeout_secs.saturating_mul(1000);
let default_item = match settings.timeout_action { let default_item = match settings.timeout_action {
TimeoutAction::LocalHdd => "local", TimeoutAction::QueuedDeployment => "queue",
TimeoutAction::GatedDeployment => "gate",
// Stay -> iPXE's `--timeout 0` is "no timeout". Pick any default // Stay -> iPXE's `--timeout 0` is "no timeout". Pick any default
// label; the client waits for keypress. // label; the client waits for keypress. We use the same label as
TimeoutAction::Stay => "local", // LocalHdd to keep the menu's pre-highlight stable.
TimeoutAction::LocalHdd | TimeoutAction::Stay => "local",
}; };
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "# PXEForge top-level menu - auto-generated, do not edit"); let _ = writeln!(s, "# OpenPXE top-level menu - auto-generated, do not edit");
let _ = writeln!(s, "set base-url {base}"); let _ = writeln!(s, "set base-url {base}");
let _ = writeln!(s, "set esc:hex 1b"); let _ = writeln!(s, "set esc:hex 1b");
let _ = writeln!(s, "set cls ${{esc:string}}[2J"); let _ = writeln!(s, "set cls ${{esc:string}}[2J");
// v0.4.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 --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").
// iPXE evaluates `iseq` lazily, so we only set whichever line
// matches. Anything not on the allowlist falls through to a generic
// `<buildarch> <platform>` display.
let _ = writeln!(s, "set arch-label ${{buildarch}} ${{platform}}");
let _ = writeln!(
s,
"iseq ${{buildarch}} i386 && iseq ${{platform}} pcbios && set arch-label x86 BIOS || iseq ${{buildarch}} x86_64 && iseq ${{platform}} efi && set arch-label x86_64 UEFI || iseq ${{buildarch}} arm64 && iseq ${{platform}} efi && set arch-label arm64 UEFI || true"
);
let _ = writeln!(s, ":menu"); let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu PXEForge - network boot menu"); let _ = writeln!(s, "menu OpenPXE - network boot menu");
let _ = writeln!(s, "item --gap -- ------------------------- Default -------------------------"); // 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,
"item --gap -- ------------------------- Default -------------------------"
);
let _ = writeln!(s, "item local Boot from Local HDD"); let _ = writeln!(s, "item local Boot from Local HDD");
let _ = writeln!(s, "item --gap -- ----------------------- Installers -----------------------"); let _ = writeln!(
s,
"item --gap -- ----------------------- Installers -----------------------"
);
if has_family(isos, is_linux_family) { if has_family(isos, is_linux_family) {
let _ = writeln!(s, "item linux Linux Installers >"); let _ = writeln!(s, "item linux Linux Installers >");
} else { } else {
@@ -64,28 +108,67 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
} else { } else {
let _ = writeln!(s, "item --gap -- (Windows support disabled in Settings)"); let _ = writeln!(s, "item --gap -- (Windows support disabled in Settings)");
} }
let _ = writeln!(s, "item --gap -- -------------------------- Tools --------------------------"); let _ = writeln!(
s,
"item --gap -- -------------------------- Tools --------------------------"
);
let _ = writeln!(s, "item tools Tools >"); let _ = writeln!(s, "item tools Tools >");
let _ = writeln!(s, "item --gap -- ---------------------- Gated Deployment ---------------------"); let _ = writeln!(
let _ = writeln!(s, "item gate Gated Deployment (join queue)"); s,
"item --gap -- ---------------------- Queued Deployment ---------------------"
);
let _ = writeln!(s, "item queue Queued Deployment (join queue)");
let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key x exit Exit iPXE"); let _ = writeln!(s, "item --key x exit Exit iPXE");
// v0.4.6 footer line. Sits just above the `choose` line so it's
// always visible regardless of how the menu paginates. iPXE
// interpolates `${arch-label}` (set near the top of this script)
// and `${version}` is the binary-baked iPXE version — *not* the
// OpenPXE version — so we hard-code the OpenPXE version string
// here.
let openpxe_version = env!("CARGO_PKG_VERSION");
let _ = writeln!(s, "item --gap");
let _ = writeln!(
s,
"item --gap -- OpenPXE v{openpxe_version} - ${{arch-label}}"
);
if matches!(settings.timeout_action, TimeoutAction::Stay) { if matches!(settings.timeout_action, TimeoutAction::Stay) {
let _ = writeln!(s, "choose --default {default_item} target || goto menu"); let _ = writeln!(s, "choose --default {default_item} target || goto menu");
} else { } else {
let _ = writeln!(s, "choose --default {default_item} --timeout {timeout_ms} target || goto menu"); let _ = writeln!(
s,
"choose --default {default_item} --timeout {timeout_ms} target || goto menu"
);
} }
// iPXE's `||` is strict about what follows. Each test uses `goto menu` // iPXE's `||` is strict about what follows. Each test uses `goto menu`
// as the fallthrough target so the parser never sees a bare `||` with // as the fallthrough target so the parser never sees a bare `||` with
// trailing whitespace — some iPXE builds reject that. // trailing whitespace — some iPXE builds reject that.
let _ = writeln!(s, "iseq ${{target}} local && chain {base}/boot/_local.ipxe || goto menu"); let _ = writeln!(
let _ = writeln!(s, "iseq ${{target}} linux && chain {base}/boot/_linux_menu.ipxe || goto menu"); s,
let _ = writeln!(s, "iseq ${{target}} windows && chain {base}/boot/_windows_menu.ipxe || goto menu"); "iseq ${{target}} local && chain {base}/boot/_local.ipxe || goto menu"
let _ = writeln!(s, "iseq ${{target}} tools && chain {base}/boot/_tools_menu.ipxe || goto menu"); );
let _ = writeln!(s, "iseq ${{target}} gate && chain {base}/boot/_gate.ipxe || goto menu"); let _ = writeln!(
let _ = writeln!(s, "iseq ${{target}} exit && exit || goto menu"); s,
"iseq ${{target}} linux && chain {base}/boot/_linux_menu.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} windows && chain {base}/boot/_windows_menu.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} tools && chain {base}/boot/_tools_menu.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} queue && chain {base}/boot/_queue.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} exit && exit || goto menu"
);
let _ = writeln!(s, "goto menu"); let _ = writeln!(s, "goto menu");
s s
} }
@@ -95,25 +178,51 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
#[must_use] #[must_use]
pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) -> String { pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) -> String {
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
let title = if is_windows { "Windows Installers" } else { "Linux Installers" }; let title = if is_windows {
"Windows Installers"
} else {
"Linux Installers"
};
let label = if is_windows { "windows" } else { "linux" }; let label = if is_windows { "windows" } else { "linux" };
let mut s = String::new(); let mut s = String::new();
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "set base-url {base}"); let _ = writeln!(s, "set base-url {base}");
let _ = writeln!(s, ":menu"); let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu PXEForge - {title}"); let _ = writeln!(s, "menu OpenPXE - {title}");
let filter: fn(DistroFamily) -> bool = let filter: fn(DistroFamily) -> bool = if is_windows {
if is_windows { is_windows_family } else { is_linux_family }; is_windows_family
} else {
is_linux_family
};
let mut count = 0; let mut count = 0;
for iso in isos { for iso in isos {
if !filter(iso.introspection.family) { continue; } if !filter(iso.introspection.family) {
continue;
}
// v0.4.4: ISOs the operator flipped to the Tools category move
// out of the OS installer submenus entirely — they only appear
// under Tools. Without this filter the operator would see the
// same ISO in both menus.
if matches!(iso.category, openpxe_iso_store::IsoCategory::Tools) {
continue;
}
for entry in &iso.boot_entries { for entry in &iso.boot_entries {
let size_label = fmt_size_mib(iso.size_bytes); let size_label = fmt_size_mib(iso.size_bytes);
let key = hotkey_for_index(count); let key = hotkey_for_index(count);
// Visual hint: a leading `*` marks password-protected entries.
// ASCII only — iPXE's menu console mangles non-ASCII on some
// firmwares.
let lock = if iso.is_password_protected() {
"*"
} else {
" "
};
let _ = writeln!( let _ = writeln!(
s, "item {}{} [{:>6}] {}", s,
"item {}{} {}[{:>6}] {}",
key, key,
entry.id, entry.id,
lock,
size_label, size_label,
escape_label(&entry.title), escape_label(&entry.title),
); );
@@ -126,8 +235,18 @@ pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) ->
let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key b back < Back to main menu"); let _ = writeln!(s, "item --key b back < Back to main menu");
let _ = writeln!(s, "choose target || goto menu"); let _ = writeln!(s, "choose target || goto menu");
let _ = writeln!(s, "iseq ${{target}} back && chain {base}/boot.ipxe || goto menu"); let _ = writeln!(
let _ = writeln!(s, "chain {base}/boot/${{target}}.ipxe || goto menu"); s,
"iseq ${{target}} back && chain {base}/boot.ipxe || goto menu"
);
// Pass `?mac=${mac}` so the per-entry handler can record the booting
// client into the Host log. iPXE substitutes `${mac}` before
// the HTTP fetch; if the firmware can't resolve it the literal
// `${mac}` is sent and the server treats it as "unknown".
let _ = writeln!(
s,
"chain {base}/boot/${{target}}.ipxe?mac=${{mac}} || goto menu"
);
s s
} }
@@ -135,7 +254,7 @@ pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) ->
/// operator expectations from the original tool). /// operator expectations from the original tool).
fn fmt_size_mib(bytes: u64) -> String { fn fmt_size_mib(bytes: u64) -> String {
let mib = bytes / (1024 * 1024); let mib = bytes / (1024 * 1024);
format!("{} MB", mib) format!("{mib} MB")
} }
/// Assign `--key N <id>` hotkeys 1..9, then nothing for positions >=9. /// Assign `--key N <id>` hotkeys 1..9, then nothing for positions >=9.
@@ -148,17 +267,54 @@ fn hotkey_for_index(i: usize) -> String {
} }
} }
/// Tools submenu — Utilities, Shell, NIC Info, Reboot, Exit to firmware. /// Tools submenu — Utilities, Shell, NIC Info, Reboot, Exit to firmware,
/// plus any ISOs the operator flipped to [`IsoCategory::Tools`] in the
/// Storage tab. The category-Tools ISOs render first so frequently used
/// recovery / hardware tools are reachable with a single number key
/// before the built-in shortcuts.
#[must_use] #[must_use]
pub fn render_tools_menu(base_url: &str) -> String { pub fn render_tools_menu(isos: &[IsoMeta], base_url: &str) -> String {
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
let mut s = String::new(); let mut s = String::new();
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "set base-url {base}"); let _ = writeln!(s, "set base-url {base}");
let _ = writeln!(s, ":menu"); let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu PXEForge - Tools"); let _ = writeln!(s, "menu OpenPXE - Tools");
// Operator-categorized tool ISOs (hotkeys 1..9), each chained the
// same way as a per-family menu pick — through the boot-entry id
// route, carrying `?mac=${mac}` for Host log attribution.
let mut count = 0;
for iso in isos {
if !matches!(iso.category, openpxe_iso_store::IsoCategory::Tools) {
continue;
}
for entry in &iso.boot_entries {
let size_label = fmt_size_mib(iso.size_bytes);
let key = hotkey_for_index(count);
let lock = if iso.is_password_protected() {
"*"
} else {
" "
};
let _ = writeln!(
s,
"item {}{} {}[{:>6}] {}",
key,
entry.id,
lock,
size_label,
escape_label(&entry.title),
);
count += 1;
}
}
if count > 0 {
let _ = writeln!(s, "item --gap");
}
let _ = writeln!(s, "item --key u util Utilities (memtest, ...)"); let _ = writeln!(s, "item --key u util Utilities (memtest, ...)");
let _ = writeln!(s, "item --key s shell PXEForge Shell"); let _ = writeln!(s, "item --key s shell OpenPXE Shell");
let _ = writeln!(s, "item --key n nic Network Card Info"); let _ = writeln!(s, "item --key n nic Network Card Info");
let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key r reboot Reboot Computer"); let _ = writeln!(s, "item --key r reboot Reboot Computer");
@@ -166,13 +322,36 @@ pub fn render_tools_menu(base_url: &str) -> String {
let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key b back < Back to main menu"); let _ = writeln!(s, "item --key b back < Back to main menu");
let _ = writeln!(s, "choose target || goto menu"); let _ = writeln!(s, "choose target || goto menu");
let _ = writeln!(s, "iseq ${{target}} util && chain {base}/boot/_util.ipxe || goto menu"); let _ = writeln!(
let _ = writeln!(s, "iseq ${{target}} shell && chain {base}/boot/_shell.ipxe || goto menu"); s,
let _ = writeln!(s, "iseq ${{target}} nic && chain {base}/boot/_nic.ipxe || goto menu"); "iseq ${{target}} util && chain {base}/boot/_util.ipxe || goto menu"
let _ = writeln!(s, "iseq ${{target}} reboot && reboot || goto menu"); );
let _ = writeln!(s, "iseq ${{target}} firmware && exit 0 || goto menu"); let _ = writeln!(
let _ = writeln!(s, "iseq ${{target}} back && chain {base}/boot.ipxe || goto menu"); s,
let _ = writeln!(s, "goto menu"); "iseq ${{target}} shell && chain {base}/boot/_shell.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} nic && chain {base}/boot/_nic.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} reboot && reboot || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} firmware && exit 0 || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} back && chain {base}/boot.ipxe || goto menu"
);
// Fall-through for category-Tools ISO ids — same as the family
// submenu, carrying `?mac=${mac}` for the boot log.
let _ = writeln!(
s,
"chain {base}/boot/${{target}}.ipxe?mac=${{mac}} || goto menu"
);
s s
} }
@@ -185,8 +364,15 @@ pub fn render_local_hdd(base_url: &str) -> String {
let mut s = String::new(); let mut s = String::new();
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "# Boot from Local HDD - platform-sensitive"); let _ = writeln!(s, "# Boot from Local HDD - platform-sensitive");
let _ = writeln!(s, "iseq ${{platform}} pcbios && sanboot --no-describe --drive 0x80 || "); let _ = writeln!(
let _ = writeln!(s, "# UEFI path: fall through to the firmware's next boot entry"); s,
"iseq ${{platform}} pcbios && sanboot --no-describe --drive 0x80 || goto uefi"
);
let _ = writeln!(s, ":uefi");
let _ = writeln!(
s,
"# UEFI path: fall through to the firmware's next boot entry"
);
let _ = writeln!(s, "exit 0"); let _ = writeln!(s, "exit 0");
let _ = writeln!(s, "# If the above exit returns, loop back to the main menu"); let _ = writeln!(s, "# If the above exit returns, loop back to the main menu");
let _ = writeln!(s, "chain {base}/boot.ipxe"); let _ = writeln!(s, "chain {base}/boot.ipxe");
@@ -202,25 +388,31 @@ pub fn render_util(base_url: &str) -> String {
let mut s = String::new(); let mut s = String::new();
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, ":menu"); let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu PXEForge - Utilities"); let _ = writeln!(s, "menu OpenPXE - Utilities");
let _ = writeln!(s, "item memtest MemTest86+ (RAM diagnostic)"); let _ = writeln!(s, "item memtest MemTest86+ (RAM diagnostic)");
let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item back < Back"); let _ = writeln!(s, "item back < Back");
let _ = writeln!(s, "choose target || goto menu"); let _ = writeln!(s, "choose target || goto menu");
let _ = writeln!(s, "iseq ${{target}} memtest && chain {base}/ipxe/memtest.bin || "); let _ = writeln!(
let _ = writeln!(s, "iseq ${{target}} back && chain {base}/boot/_tools_menu.ipxe || "); s,
"iseq ${{target}} memtest && chain {base}/ipxe/memtest.bin || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} back && chain {base}/boot/_tools_menu.ipxe || goto menu"
);
let _ = writeln!(s, "goto menu"); let _ = writeln!(s, "goto menu");
s s
} }
/// "PXEForge Shell" — iPXE shell, branded. /// "OpenPXE Shell" — iPXE shell, branded.
#[must_use] #[must_use]
pub fn render_shell(base_url: &str) -> String { pub fn render_shell(base_url: &str) -> String {
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
let mut s = String::new(); let mut s = String::new();
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "echo =========================================="); let _ = writeln!(s, "echo ==========================================");
let _ = writeln!(s, "echo PXEForge Shell"); let _ = writeln!(s, "echo OpenPXE Shell");
let _ = writeln!(s, "echo 'exit' returns to the main menu"); let _ = writeln!(s, "echo 'exit' returns to the main menu");
let _ = writeln!(s, "echo =========================================="); let _ = writeln!(s, "echo ==========================================");
let _ = writeln!(s, "shell"); let _ = writeln!(s, "shell");
@@ -247,37 +439,63 @@ pub fn render_nic_info(base_url: &str) -> String {
s s
} }
/// Gated Deployment entry point. Joins the queue, then enters a long-poll /// Queued Deployment entry point. Joins the queue, then enters a long-poll
/// loop (iPXE repeats the chain on 3xx redirects / HTTP errors until a /// loop (iPXE repeats the chain on 3xx redirects / HTTP errors until a
/// real script comes back). /// real script comes back).
#[must_use] #[must_use]
pub fn render_gate_entry(base_url: &str) -> String { pub fn render_queue_entry(base_url: &str) -> String {
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
let mut s = String::new(); let mut s = String::new();
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "# Gated Deployment - join the queue and wait for operator"); let _ = writeln!(
let _ = writeln!(s, "echo Joining gate queue..."); s,
"# Queued Deployment - join the queue and wait for operator"
);
let _ = writeln!(s, "echo Joining deployment queue...");
// imgfetch writes the body to a file in iPXE's transient FS; we read // imgfetch writes the body to a file in iPXE's transient FS; we read
// the gate id out of the Location-style header by asking the server // the queue entry id out of the Location-style header by asking the server
// to put it in the response body as a single token. // to put it in the response body as a single token.
let _ = writeln!(s, "chain --replace {base}/api/gate/join?mac=${{mac}}"); let _ = writeln!(s, "chain --replace {base}/api/queue/join?mac=${{mac}}");
s s
} }
/// Per-entry boot script (same as Phase 1, with extra_kernel_args appended). /// Per-entry boot script (same as Phase 1, with extra_kernel_args appended).
///
/// `unattended_args` (v0.5.2) carries the per-host unattended-install
/// kernel arguments (`inst.ks=…`, `auto=true … url=…`, or
/// `autoinstall ds=nocloud-net;s=…`) when the requesting MAC has a
/// deployment profile with an answer file selected. It's appended to the
/// Linux kernel command line after the operator's global extra args, and
/// ignored for Windows (wimboot) / sanboot entries which don't take a
/// kernel cmdline.
#[must_use] #[must_use]
pub fn render_entry(entry: &BootEntry, settings: &Settings, base_url: &str) -> String { pub fn render_entry(
entry: &BootEntry,
settings: &Settings,
base_url: &str,
unattended_args: Option<&str>,
) -> String {
let mut s = String::new(); let mut s = String::new();
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "set base-url {base}"); let _ = writeln!(s, "set base-url {base}");
match &entry.kind { match &entry.kind {
BootKind::LinuxKernel { kernel_url, initrd_urls, args } => { BootKind::LinuxKernel {
kernel_url,
initrd_urls,
args,
} => {
let mut cmdline = args.cmdline.replace("${base-url}", base); let mut cmdline = args.cmdline.replace("${base-url}", base);
if !settings.extra_kernel_args.trim().is_empty() { if !settings.extra_kernel_args.trim().is_empty() {
cmdline.push(' '); cmdline.push(' ');
cmdline.push_str(settings.extra_kernel_args.trim()); cmdline.push_str(settings.extra_kernel_args.trim());
} }
if let Some(extra) = unattended_args {
if !extra.trim().is_empty() {
cmdline.push(' ');
cmdline.push_str(extra.trim());
}
}
let _ = writeln!(s, "kernel {base}/{kernel_url} {cmdline}"); let _ = writeln!(s, "kernel {base}/{kernel_url} {cmdline}");
for u in initrd_urls { for u in initrd_urls {
let _ = writeln!(s, "initrd {base}/{u}"); let _ = writeln!(s, "initrd {base}/{u}");
@@ -323,5 +541,178 @@ fn has_family(isos: &[IsoMeta], pred: fn(DistroFamily) -> bool) -> bool {
} }
fn escape_label(s: &str) -> String { fn escape_label(s: &str) -> String {
s.chars().map(|c| match c { '\n' | '\r' => ' ', c => c }).collect() s.chars()
.map(|c| match c {
'\n' | '\r' => ' ',
c => c,
})
.collect()
}
/// Render the password-prompt script for a protected boot entry.
///
/// Flow on the client:
/// 1. iPXE clears any leftover ${password}, prints a banner naming the
/// ISO so the operator knows what they're being asked for.
/// 2. `read --secret password` accepts input without echoing it to
/// the screen.
/// 3. An empty input bails back to the main menu (lets the operator
/// back out of a misclick).
/// 4. Otherwise the script chains the same /boot/<id>.ipxe URL but
/// with `?token=${password:uristring}`. iPXE's `:uristring`
/// modifier URL-encodes the value so `&`, `?`, `=`, spaces, etc.
/// survive transport.
/// 5. The server replies with either the boot script (correct
/// password) or [`render_password_failed`] (wrong password). On
/// transport failure we fall back to the main menu.
#[must_use]
pub fn render_password_prompt(entry_id: &str, iso_filename: &str, base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let label = escape_label(iso_filename);
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "# OpenPXE password prompt for {label}");
let _ = writeln!(s, "echo");
let _ = writeln!(s, "echo ==========================================");
let _ = writeln!(s, "echo This image requires a password");
let _ = writeln!(s, "echo {label}");
let _ = writeln!(s, "echo (enter alone returns to main menu)");
let _ = writeln!(s, "echo ==========================================");
let _ = writeln!(s, "set password ");
let _ = writeln!(s, "read --secret password");
let _ = writeln!(
s,
"iseq ${{password}} \"\" && chain {base}/boot.ipxe || goto submit"
);
let _ = writeln!(s, ":submit");
let _ = writeln!(s, "echo Verifying...");
// Carry `mac=${mac}` alongside the token so a successful unlock
// records the actual client MAC into the Host log. On
// older iPXE that can't resolve `${mac}` the server just stores it
// as "unknown" rather than refusing to boot.
let _ = writeln!(
s,
"chain {base}/boot/{entry_id}.ipxe?token=${{password:uristring}}&mac=${{mac}} \
|| chain {base}/boot.ipxe"
);
s
}
/// Render the "wrong password" script. Tells the operator, sleeps for
/// two seconds (gives the eye time to register the message and dampens
/// brute-force rate without help from the server), and chains back to
/// the same entry — which sends them through the prompt flow again.
#[must_use]
pub fn render_password_failed(entry_id: &str, base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let mut s = String::new();
let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "echo");
let _ = writeln!(s, "echo Wrong password.");
let _ = writeln!(s, "sleep 2");
let _ = writeln!(
s,
"chain {base}/boot/{entry_id}.ipxe || chain {base}/boot.ipxe"
);
s
}
#[cfg(test)]
mod password_tests {
use super::*;
#[test]
fn prompt_uses_secret_read_and_uri_escape() {
let s = render_password_prompt("alpha-linux", "Alpha Test.iso", "http://10.0.0.5");
assert!(s.starts_with("#!ipxe\n"));
assert!(s.contains("read --secret password"));
assert!(s.contains("Alpha Test.iso"));
// URI-string modifier on the var so passwords with `&`/spaces survive.
assert!(s.contains("token=${password:uristring}"));
// Empty enter sends back to the main menu, not back into the prompt
// (avoids a wedged client if the operator chose by mistake).
assert!(s.contains("&& chain http://10.0.0.5/boot.ipxe || goto submit"));
// Never log/echo the value.
assert!(!s.contains("echo ${password"));
}
#[test]
fn failed_chains_back_to_entry() {
let s = render_password_failed("alpha-linux", "http://10.0.0.5");
assert!(s.contains("Wrong password."));
// Re-target the entry so the prompt flow runs again.
assert!(s.contains("chain http://10.0.0.5/boot/alpha-linux.ipxe"));
}
#[test]
fn top_menu_has_polished_branding_and_arch_footer() {
// 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}");
// The ASCII wordmark must be GONE — its removal is the whole
// point of v0.4.69's graphical background.
assert!(
!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.
let version = env!("CARGO_PKG_VERSION");
assert!(
s.contains(&format!("OpenPXE v{version}")),
"footer missing OpenPXE version:\n{s}"
);
assert!(
s.contains("${arch-label}"),
"footer missing arch-label interpolation:\n{s}"
);
// No website URL — the design brief calls that out as tacky.
assert!(
!s.to_ascii_lowercase().contains("openpxe.com"),
"footer should not advertise the website:\n{s}"
);
// Arch-label mapping covers the three labels from the brief:
// "x86 BIOS", "x86_64 UEFI", "arm64 UEFI".
assert!(s.contains("x86 BIOS"), "{s}");
assert!(s.contains("x86_64 UEFI"), "{s}");
assert!(s.contains("arm64 UEFI"), "{s}");
}
#[test]
fn generated_scripts_do_not_emit_bare_or_trailing_fallbacks() {
let settings = Settings::default();
let scripts = [
render_menu(&[], &settings, "http://10.0.0.5"),
render_tools_menu(&[], "http://10.0.0.5"),
render_local_hdd("http://10.0.0.5"),
render_util("http://10.0.0.5"),
render_shell("http://10.0.0.5"),
render_nic_info("http://10.0.0.5"),
render_queue_entry("http://10.0.0.5"),
render_password_failed("alpha-linux", "http://10.0.0.5"),
];
for script in scripts {
for line in script.lines() {
assert!(
!line.trim_end().ends_with("||"),
"bare iPXE fallback operator in line: {line}\nscript:\n{script}"
);
}
}
}
} }
+33 -10
View File
@@ -31,7 +31,9 @@ pub fn lookup(iso_path: &Path, in_iso_path: &str) -> Option<FileLocation> {
.split('/') .split('/')
.filter(|c| !c.is_empty()) .filter(|c| !c.is_empty())
.collect(); .collect();
if components.is_empty() { return None; } if components.is_empty() {
return None;
}
walk(&mut f, root.offset, root.length, &components) walk(&mut f, root.offset, root.length, &components)
} }
@@ -40,11 +42,16 @@ fn read_root_directory(f: &mut std::fs::File) -> Option<FileLocation> {
let mut pvd = [0u8; 2048]; let mut pvd = [0u8; 2048];
f.seek(SeekFrom::Start(16 * SECTOR)).ok()?; f.seek(SeekFrom::Start(16 * SECTOR)).ok()?;
f.read_exact(&mut pvd).ok()?; f.read_exact(&mut pvd).ok()?;
if pvd[0] != 0x01 || &pvd[1..6] != b"CD001" { return None; } if pvd[0] != 0x01 || &pvd[1..6] != b"CD001" {
return None;
}
// Root directory record is at offset 156, length 34. // Root directory record is at offset 156, length 34.
let rec = &pvd[156..156 + 34]; let rec = &pvd[156..156 + 34];
let (offset, length) = parse_dir_record_ext(rec)?; let (offset, length) = parse_dir_record_ext(rec)?;
Some(FileLocation { offset: offset * SECTOR, length }) Some(FileLocation {
offset: offset * SECTOR,
length,
})
} }
/// Walk components down the directory tree starting at `dir_offset`. /// Walk components down the directory tree starting at `dir_offset`.
@@ -66,21 +73,29 @@ fn walk(
if len == 0 { if len == 0 {
// Padding to sector boundary. // Padding to sector boundary.
let next = (i / SECTOR as usize + 1) * SECTOR as usize; let next = (i / SECTOR as usize + 1) * SECTOR as usize;
if next <= i { break; } if next <= i {
break;
}
i = next; i = next;
continue; continue;
} }
if i + len > dir.len() { break; } if i + len > dir.len() {
break;
}
let rec = &dir[i..i + len]; let rec = &dir[i..i + len];
let name = dir_record_name(rec); let name = dir_record_name(rec);
let is_dir = (rec.get(25).copied().unwrap_or(0) & 0x02) != 0; let is_dir = (rec.get(25).copied().unwrap_or(0) & 0x02) != 0;
// Skip "." (0x00) and ".." (0x01) pseudo-entries. // Skip "." (0x00) and ".." (0x01) pseudo-entries.
let is_pseudo = matches!(rec.get(32).copied(), Some(1)) && rec.get(33).copied() == Some(0x00) let is_pseudo = matches!(rec.get(32).copied(), Some(1))
&& rec.get(33).copied() == Some(0x00)
|| matches!(rec.get(32).copied(), Some(1)) && rec.get(33).copied() == Some(0x01); || matches!(rec.get(32).copied(), Some(1)) && rec.get(33).copied() == Some(0x01);
if !is_pseudo && name.eq_ignore_ascii_case(target) { if !is_pseudo && name.eq_ignore_ascii_case(target) {
let (child_off, child_len) = parse_dir_record_ext(rec)?; let (child_off, child_len) = parse_dir_record_ext(rec)?;
if rest.is_empty() && !is_dir { if rest.is_empty() && !is_dir {
return Some(FileLocation { offset: child_off * SECTOR, length: child_len }); return Some(FileLocation {
offset: child_off * SECTOR,
length: child_len,
});
} else if !rest.is_empty() && is_dir { } else if !rest.is_empty() && is_dir {
return walk(f, child_off * SECTOR, child_len, rest); return walk(f, child_off * SECTOR, child_len, rest);
} }
@@ -94,7 +109,9 @@ fn walk(
/// Layout per ISO9660: bytes 2..10 extent LBA (LE+BE duplicate), 10..18 /// Layout per ISO9660: bytes 2..10 extent LBA (LE+BE duplicate), 10..18
/// data length (LE+BE duplicate). We trust the little-endian copy. /// data length (LE+BE duplicate). We trust the little-endian copy.
fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> { fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> {
if rec.len() < 34 { return None; } if rec.len() < 34 {
return None;
}
let lba = u32::from_le_bytes(rec[2..6].try_into().ok()?) as u64; let lba = u32::from_le_bytes(rec[2..6].try_into().ok()?) as u64;
let len = u32::from_le_bytes(rec[10..14].try_into().ok()?) as u64; let len = u32::from_le_bytes(rec[10..14].try_into().ok()?) as u64;
Some((lba, len)) Some((lba, len))
@@ -104,9 +121,15 @@ fn parse_dir_record_ext(rec: &[u8]) -> Option<(u64, u64)> {
/// `;1` version suffix. /// `;1` version suffix.
fn dir_record_name(rec: &[u8]) -> String { fn dir_record_name(rec: &[u8]) -> String {
let name_len = *rec.get(32).unwrap_or(&0) as usize; let name_len = *rec.get(32).unwrap_or(&0) as usize;
if name_len == 0 || rec.len() < 33 + name_len { return String::new(); } if name_len == 0 || rec.len() < 33 + name_len {
return String::new();
}
let raw = &rec[33..33 + name_len]; let raw = &rec[33..33 + name_len];
let s = String::from_utf8_lossy(raw).to_string(); let s = String::from_utf8_lossy(raw).to_string();
// Strip `;N` version suffix. // Strip `;N` version suffix.
if let Some(i) = s.rfind(';') { s[..i].to_string() } else { s } if let Some(i) = s.rfind(';') {
s[..i].to_string()
} else {
s
}
} }
+5 -1
View File
@@ -1,5 +1,5 @@
//! HTTP server — single axum app that serves: //! HTTP server — single axum app that serves:
//! - `/` the web UI (static assets from `pxeforge-webui`) //! - `/` the web UI (static assets from `openpxe-webui`)
//! - `/api/*` JSON API for the web UI //! - `/api/*` JSON API for the web UI
//! - `/boot.ipxe` the generated top-level iPXE boot menu //! - `/boot.ipxe` the generated top-level iPXE boot menu
//! - `/boot/<entry>.ipxe` per-entry iPXE scripts (one per boot target) //! - `/boot/<entry>.ipxe` per-entry iPXE scripts (one per boot target)
@@ -14,11 +14,15 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
pub mod app; pub mod app;
pub mod auth;
pub mod ipxe_script; pub mod ipxe_script;
pub mod iso_fs; pub mod iso_fs;
pub mod log_stream; pub mod log_stream;
pub mod notify;
pub mod saml_routes;
pub mod state; pub mod state;
pub mod terminal; pub mod terminal;
pub mod uploads;
pub use app::build_router; pub use app::build_router;
pub use state::AppState; pub use state::AppState;
+10 -6
View File
@@ -12,7 +12,7 @@ use axum::{
Json, Json,
}; };
use futures::stream::{Stream, StreamExt}; use futures::stream::{Stream, StreamExt};
use pxeforge_core::LogLine; use openpxe_core::LogLine;
use serde_json::json; use serde_json::json;
use std::convert::Infallible; use std::convert::Infallible;
use std::time::Duration; use std::time::Duration;
@@ -36,9 +36,11 @@ pub async fn stream(
let rx = state.log_bus.subscribe(); let rx = state.log_bus.subscribe();
let live = BroadcastStream::new(rx).map(|res| match res { let live = BroadcastStream::new(rx).map(|res| match res {
Ok(line) => Ok(Event::default().data(line_json(&line))), Ok(line) => Ok(Event::default().data(line_json(&line))),
Err(tokio_stream::wrappers::errors::BroadcastStreamRecvError::Lagged(n)) => Ok(Event::default() Err(tokio_stream::wrappers::errors::BroadcastStreamRecvError::Lagged(n)) => {
Ok(Event::default()
.event("lagged") .event("lagged")
.data(json!({ "skipped": n }).to_string())), .data(json!({ "skipped": n }).to_string()))
}
}); });
Sse::new(recent_stream.chain(live)) Sse::new(recent_stream.chain(live))
@@ -55,9 +57,11 @@ pub async fn recent(State(state): State<AppState>) -> Json<serde_json::Value> {
/// keep streaming new lines as they arrive). /// keep streaming new lines as they arrive).
pub async fn clear(State(state): State<AppState>) -> Json<serde_json::Value> { pub async fn clear(State(state): State<AppState>) -> Json<serde_json::Value> {
state.log_bus.clear(); state.log_bus.clear();
state state.log_bus.push(
.log_bus "info",
.push("info", "pxeforge::terminal", "log buffer cleared by operator"); "openpxe::terminal",
"log buffer cleared by operator",
);
Json(json!({ "ok": true })) Json(json!({ "ok": true }))
} }
+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}"))
}
+338
View File
@@ -0,0 +1,338 @@
//! SAML 2.0 Service Provider HTTP endpoints (v0.5.1).
//!
//! * `GET /api/sso/login` — SP-initiated: build an AuthnRequest, record its
//! ID, and 302 the browser to the IdP.
//! * `POST /api/sso/acs` — Assertion Consumer Service: verify + validate
//! the IdP's `SAMLResponse`, perform the stateful checks (InResponseTo
//! correlation, IdP-initiated gating, assertion replay), mint an operator
//! session, and 302 to the dashboard. (Mirrors FleetDM's `/sso/callback`.)
//! * `GET /api/sso/metadata` — serve our SP metadata XML for IdP import.
//!
//! Stateless crypto + semantic validation live in `openpxe_core::saml`; this
//! module owns only the HTTP glue and the in-memory state the SP needs.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration as StdDuration, Instant};
use axum::{
body::Body,
extract::{Form, Query, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
};
use base64::Engine;
use parking_lot::Mutex;
use serde::Deserialize;
use time::{Duration, OffsetDateTime};
use openpxe_core::saml::{self, metadata::IdpMetadata, SamlError, SpParams};
use openpxe_core::SsoConfig;
use crate::auth;
use crate::state::AppState;
/// Outstanding AuthnRequest IDs live at most this long before a matching
/// response is considered stale (covers a slow human at the IdP login form).
const REQUEST_TTL: StdDuration = StdDuration::from_mins(10);
/// How long we fetch-cache IdP metadata loaded from a URL.
const METADATA_FETCH_TIMEOUT: StdDuration = StdDuration::from_secs(10);
/// In-memory SAML runtime state. Cheap to clone (Arc-shared).
#[derive(Clone, Default)]
pub struct SamlRuntime {
/// request_id → issued_at. Correlates a response's `InResponseTo` to a
/// request *we* actually sent (replay / CSRF defense for SP-initiated).
outstanding: Arc<Mutex<HashMap<String, Instant>>>,
/// assertion_id → expiry. A consumed assertion may not be replayed.
consumed: Arc<Mutex<HashMap<String, Instant>>>,
/// Cache of IdP metadata fetched from a URL: (url, parsed).
metadata_cache: Arc<Mutex<Option<(String, IdpMetadata)>>>,
}
impl SamlRuntime {
/// Record an AuthnRequest we just sent.
pub fn register_request(&self, id: &str) {
let mut g = self.outstanding.lock();
prune(&mut g);
g.insert(id.to_owned(), Instant::now());
}
/// Consume an outstanding request ID, returning `true` if it was present
/// and still fresh. A miss means the response doesn't correlate to any
/// live request we issued.
pub fn take_request(&self, id: &str) -> bool {
let mut g = self.outstanding.lock();
prune(&mut g);
g.remove(id).is_some()
}
/// Record a consumed assertion. Returns `false` if it was already
/// consumed (a replay) — in which case the caller must reject.
pub fn record_assertion(&self, id: &str, expiry: OffsetDateTime) -> bool {
let mut g = self.consumed.lock();
prune(&mut g);
if g.contains_key(id) {
return false;
}
let ttl = (expiry - OffsetDateTime::now_utc())
.max(Duration::ZERO)
.unsigned_abs();
g.insert(id.to_owned(), Instant::now() + ttl);
true
}
fn cached_metadata(&self, url: &str) -> Option<IdpMetadata> {
let g = self.metadata_cache.lock();
match &*g {
Some((cached_url, md)) if cached_url == url => Some(md.clone()),
_ => None,
}
}
fn cache_metadata(&self, url: String, md: IdpMetadata) {
*self.metadata_cache.lock() = Some((url, md));
}
}
/// Drop expired entries so neither map grows unbounded.
fn prune(map: &mut HashMap<String, Instant>) {
let now = Instant::now();
// For the request map this over-prunes (entries store issued_at, not
// expiry), so cap by REQUEST_TTL; the consumed map stores absolute
// expiry instants. Using saturating logic keeps both correct: request
// entries older than REQUEST_TTL go, consumed entries past expiry go.
map.retain(|_, &mut t| now.saturating_duration_since(t) < REQUEST_TTL || t > now);
}
// ─── GET /api/sso/login ───────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct LoginQuery {
/// Optional local path to return to after login (becomes RelayState).
#[serde(default)]
pub next: Option<String>,
}
pub async fn sso_login(State(state): State<AppState>, Query(q): Query<LoginQuery>) -> Response {
let cfg = state.sso.snapshot();
if !cfg.is_usable() {
return redirect("/?sso_error=unavailable");
}
let idp = match resolve_idp_metadata(&state, &cfg).await {
Ok(m) => m,
Err(e) => {
tracing::warn!(target: "openpxe::saml", "sso_login: metadata unavailable: {e}");
return redirect("/?sso_error=metadata");
}
};
let Some(dest) = idp.sso_destination().map(str::to_owned) else {
tracing::warn!(target: "openpxe::saml", "sso_login: IdP metadata has no SSO endpoint");
return redirect("/?sso_error=metadata");
};
let sp = sp_params(&state, &cfg);
let relay = safe_local_path(q.next.as_deref());
match saml::authn_request::build(&sp, &dest, Some(&relay)) {
Ok(req) => {
state.saml.register_request(&req.id);
redirect(&req.location)
}
Err(e) => {
tracing::warn!(target: "openpxe::saml", "sso_login: build AuthnRequest failed: {e}");
redirect("/?sso_error=request")
}
}
}
// ─── POST /api/sso/acs ──────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct AcsForm {
#[serde(rename = "SAMLResponse")]
pub saml_response: String,
#[serde(rename = "RelayState", default)]
pub relay_state: Option<String>,
}
pub async fn sso_acs(State(state): State<AppState>, Form(form): Form<AcsForm>) -> Response {
let cfg = state.sso.snapshot();
if !cfg.is_usable() {
return redirect("/?sso_error=unavailable");
}
let xml = match base64::engine::general_purpose::STANDARD.decode(form.saml_response.as_bytes())
{
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
Err(e) => {
tracing::warn!(target: "openpxe::saml", "acs: base64 decode failed: {e}");
return redirect("/?sso_error=1");
}
};
let idp = match resolve_idp_metadata(&state, &cfg).await {
Ok(m) => m,
Err(e) => {
tracing::warn!(target: "openpxe::saml", "acs: metadata unavailable: {e}");
return redirect("/?sso_error=metadata");
}
};
let sp = sp_params(&state, &cfg);
// Signature verification + semantic checks are CPU-bound — keep them off
// the async executor.
let now = OffsetDateTime::now_utc();
let skew = Duration::seconds(saml::DEFAULT_CLOCK_SKEW_SECS);
let verify = {
let xml = xml.clone();
let sp = sp.clone();
tokio::task::spawn_blocking(move || saml::response::consume(&xml, &sp, &idp, now, skew))
.await
};
let verified = match verify {
Ok(Ok(v)) => v,
Ok(Err(e)) => {
// Never leak which specific check failed to the browser.
tracing::warn!(target: "openpxe::saml", "acs: response rejected: {e}");
return redirect("/?sso_error=1");
}
Err(join) => {
tracing::error!(target: "openpxe::saml", "acs: verify task panicked: {join}");
return redirect("/?sso_error=1");
}
};
// Stateful checks the core deliberately left to us.
match &verified.in_response_to {
Some(id) => {
if !state.saml.take_request(id) {
tracing::warn!(target: "openpxe::saml", "acs: InResponseTo matches no live request");
return redirect("/?sso_error=1");
}
}
None => {
if !cfg.allow_idp_initiated {
tracing::warn!(target: "openpxe::saml", "acs: IdP-initiated login is disabled");
return redirect("/?sso_error=idp_initiated");
}
}
}
if !state
.saml
.record_assertion(&verified.assertion_id, verified.assertion_expiry)
{
tracing::warn!(target: "openpxe::saml", "acs: assertion replay rejected");
return redirect("/?sso_error=1");
}
// Success → mint an operator session keyed to the verified email.
let session = state.sessions.create(&verified.principal.email);
tracing::info!(
target: "openpxe::saml",
email = %verified.principal.email,
idp_initiated = verified.in_response_to.is_none(),
"SAML SSO sign-in"
);
// safe_local_path already maps None / unsafe values to "/".
let relay = safe_local_path(form.relay_state.as_deref());
redirect_with_session(&relay, &session)
}
// ─── GET /api/sso/metadata ──────────────────────────────────────────────────
pub async fn sso_metadata(State(state): State<AppState>) -> Response {
let cfg = state.sso.snapshot();
let sp = sp_params(&state, &cfg);
let xml = saml::metadata::build_sp_metadata(&sp);
(
StatusCode::OK,
[(header::CONTENT_TYPE, "application/samlmetadata+xml")],
xml,
)
.into_response()
}
// ─── helpers ────────────────────────────────────────────────────────────────
/// Derive runtime SP parameters from config + the advertised public base URL.
fn sp_params(state: &AppState, cfg: &SsoConfig) -> SpParams {
let base = state.public_base_url.trim_end_matches('/');
let entity_id = if cfg.entity_id.trim().is_empty() {
base.to_owned()
} else {
cfg.entity_id.trim().to_owned()
};
SpParams {
entity_id,
acs_url: format!("{base}/api/sso/acs"),
}
}
/// Resolve the IdP metadata: prefer the metadata URL (fetched + cached) per
/// the "URL wins" rule, else parse the pasted XML.
async fn resolve_idp_metadata(state: &AppState, cfg: &SsoConfig) -> Result<IdpMetadata, SamlError> {
let url = cfg.metadata_url.trim();
if !url.is_empty() {
if let Some(md) = state.saml.cached_metadata(url) {
return Ok(md);
}
let body = fetch_metadata(url).await?;
let md = IdpMetadata::parse(&body)?;
state.saml.cache_metadata(url.to_owned(), md.clone());
return Ok(md);
}
if !cfg.metadata.trim().is_empty() {
return IdpMetadata::parse(&cfg.metadata);
}
Err(SamlError::Metadata("no metadata source configured".into()))
}
async fn fetch_metadata(url: &str) -> Result<String, SamlError> {
let client = reqwest::Client::builder()
.timeout(METADATA_FETCH_TIMEOUT)
.build()
.map_err(|e| SamlError::Metadata(format!("http client: {e}")))?;
let resp = client
.get(url)
.send()
.await
.map_err(|e| SamlError::Metadata(format!("fetch {url}: {e}")))?;
if !resp.status().is_success() {
return Err(SamlError::Metadata(format!(
"fetch {url}: HTTP {}",
resp.status()
)));
}
resp.text()
.await
.map_err(|e| SamlError::Metadata(format!("read {url}: {e}")))
}
/// Only permit a same-site path (single leading slash) as a redirect target —
/// blocks open-redirect / protocol-relative (`//evil.com`) abuse of RelayState.
fn safe_local_path(p: Option<&str>) -> String {
match p {
Some(p) if p.starts_with('/') && !p.starts_with("//") => p.to_owned(),
_ => "/".to_owned(),
}
}
fn redirect(location: &str) -> Response {
Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, location)
.body(Body::empty())
.map_or_else(
|_| StatusCode::INTERNAL_SERVER_ERROR.into_response(),
IntoResponse::into_response,
)
}
fn redirect_with_session(location: &str, session: &str) -> Response {
Response::builder()
.status(StatusCode::FOUND)
.header(header::LOCATION, location)
.header(header::SET_COOKIE, auth::session_cookie(session))
.body(Body::empty())
.map_or_else(
|_| StatusCode::INTERNAL_SERVER_ERROR.into_response(),
IntoResponse::into_response,
)
}
+66 -8
View File
@@ -1,5 +1,11 @@
use pxeforge_core::{ClientRegistry, GateQueue, LogBus, SettingsStore}; use crate::auth::SessionStore;
use pxeforge_iso_store::{IsoStore, NfsManager, SmbManager}; use crate::saml_routes::SamlRuntime;
use crate::uploads::UploadSessions;
use openpxe_core::{
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
Metrics, NotifyStore, SettingsStore, SsoStore,
};
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager, UnattendedStore};
use std::sync::Arc; use std::sync::Arc;
use time::OffsetDateTime; use time::OffsetDateTime;
@@ -8,16 +14,68 @@ pub struct AppState {
pub iso_store: IsoStore, pub iso_store: IsoStore,
pub clients: Arc<ClientRegistry>, pub clients: Arc<ClientRegistry>,
pub settings: Arc<SettingsStore>, pub settings: Arc<SettingsStore>,
pub gates: Arc<GateQueue>, pub queue: Arc<DeploymentQueue>,
/// Per-MAC iPXE script overrides. When a client matching one of
/// these MACs requests `/boot.ipxe`, we chain straight to the
/// configured target instead of rendering the menu.
pub hosts: HostBindings,
/// Persistent boot-event log surfaced under the Hosts tab. Records
/// every `/boot/<entry>.ipxe` chain that goes on to serve a script
/// (i.e. an image actually starting to install on a machine).
pub boot_log: BootLog,
/// Operator-controlled UI overrides (custom logo). When the
/// operator hasn't uploaded anything, the WebUI serves the bundled
/// rainbow-horizon mark.
pub branding: BrandingStore,
/// Forms-auth admin record + first-run bootstrap state. When
/// `admin.is_configured() == false`, the auth middleware passes
/// every request through and `/api/me` reports `setup_required`.
pub admin: AdminStore,
/// In-memory session table for active operator logins. Cleared on
/// process restart (sessions are tied to UI state, not persisted —
/// matches Sonarr/Radarr behaviour).
pub sessions: SessionStore,
/// SAML SSO configuration (persisted IdP metadata, Entity ID, toggles).
pub sso: SsoStore,
/// v0.5.1: in-memory SAML runtime state — outstanding AuthnRequest IDs
/// (for InResponseTo correlation), consumed-assertion replay guard, and
/// a cache of fetched IdP metadata. Tied to process lifetime, like
/// `sessions`; a restart simply invalidates any in-flight SSO login.
pub saml: SamlRuntime,
/// v0.5.0: webhook / email notification config (Slack/Teams/Discord/
/// SMTP). Drives the fire-and-forget pings on boot events and powers
/// the Advanced tab's config + "Send test" button.
pub notify: NotifyStore,
/// Lock-free metrics counters surfaced at `/metrics` in Prometheus
/// text format. Cheap to clone (handles to atomics).
pub metrics: Metrics,
/// Optional SMB manager. Present when the binary was given a writable /// Optional SMB manager. Present when the binary was given a writable
/// `smb_dir` at startup; `None` in pure-Linux-only deployments where /// `smb_dir` at startup; `None` in pure-Linux-only deployments where
/// Windows support is not wired in. Settings toggle drives start/stop. /// Windows support is not wired in. Settings toggle drives start/stop.
pub smb: Option<Arc<SmbManager>>, pub smb: Option<Arc<SmbManager>>,
/// NFS share manager. Always present (mounting is opt-in by the /// v0.4.65: SMB share manager — userspace consumer of remote SMB
/// operator from the Storage tab); `add()` requires `mount.nfs` to be /// shares via Samba's `smbclient` CLI. Replaces the kernel-mount
/// available in the runtime image. Surfaces errors per-mount rather /// NFS path that v0.4.64 shipped; that path didn't work on hosts
/// than failing the global state. /// (Unraid, etc.) whose kernel ships without the nfs/cifs client
pub nfs: NfsManager, /// modules, and no container-side configuration could fix it.
/// `smbclient` does the SMB protocol over a plain TCP socket in
/// userspace — works in any container, no special caps required.
pub smb_shares: SmbShareManager,
/// v0.4.67: NFSv3 share manager — pure-Rust userspace consumer
/// via the `nfs3_client` crate. Ships alongside the SMB manager
/// so operators pick whichever protocol their NAS prefers.
/// In-process (no subprocess); supports HTTP Range requests on
/// NFS-sourced ISOs because NFSv3 READ3 takes an explicit offset.
pub nfs_shares: NfsShareManager,
/// v0.5.2: uploaded unattended-install answer files (Kickstart /
/// Preseed / Autoinstall / Windows answer files). Served on demand to
/// booting clients with per-host hostname/IP/MAC templating; lives in
/// its own directory, never the ISO listing or PXE menu.
pub unattended: UnattendedStore,
/// Browser chunked upload state. Multipart uploads still go straight
/// through `IsoStore`, but the UI uses sessions so large ISO transfers
/// can show deterministic progress and leave visible partial files.
pub uploads: UploadSessions,
/// Live log bus consumed by the Terminal tab via SSE. Operator-issued /// Live log bus consumed by the Terminal tab via SSE. Operator-issued
/// terminal commands also push synthetic lines onto it so the tail /// terminal commands also push synthetic lines onto it so the tail
/// shows them inline. /// shows them inline.
+268 -112
View File
@@ -31,12 +31,17 @@ pub async fn run_command(
) -> impl IntoResponse { ) -> impl IntoResponse {
let line = req.command.trim(); let line = req.command.trim();
if line.is_empty() { if line.is_empty() {
return (StatusCode::OK, Json(json!({ "output": HELP_TEXT, "ok": true }))); return (
StatusCode::OK,
Json(json!({ "output": HELP_TEXT, "ok": true })),
);
} }
// Echo the typed command into the live log so the Terminal tab shows // Echo the typed command into the live log so the Terminal tab shows
// operator activity in-band with server-emitted log lines. // operator activity in-band with server-emitted log lines.
state.log_bus.push("info", "pxeforge::terminal", format!("> {line}")); state
.log_bus
.push("info", "openpxe::terminal", format!("> {line}"));
let argv = shell_split(line); let argv = shell_split(line);
if argv.is_empty() { if argv.is_empty() {
@@ -55,14 +60,18 @@ pub async fn run_command(
// so reading the live tail tells the same story as scrolling the // so reading the live tail tells the same story as scrolling the
// terminal pane. // terminal pane.
let mirror = if output.len() > 1024 { let mirror = if output.len() > 1024 {
format!("{}\n... ({} bytes truncated)", &output[..1024], output.len() - 1024) format!(
"{}\n... ({} bytes truncated)",
&output[..1024],
output.len() - 1024
)
} else { } else {
output.clone() output.clone()
}; };
if ok { if ok {
state.log_bus.push("info", "pxeforge::terminal", mirror); state.log_bus.push("info", "openpxe::terminal", mirror);
} else { } else {
state.log_bus.push("warn", "pxeforge::terminal", mirror); state.log_bus.push("warn", "openpxe::terminal", mirror);
} }
(StatusCode::OK, Json(json!({ "output": output, "ok": ok }))) (StatusCode::OK, Json(json!({ "output": output, "ok": ok })))
@@ -73,14 +82,20 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
let tail = &argv[1..]; let tail = &argv[1..];
match head { match head {
"help" | "?" => Ok(HELP_TEXT.to_string()), "help" | "?" => Ok(HELP_TEXT.to_string()),
"version" => Ok(format!("pxeforge {}", env!("CARGO_PKG_VERSION"))), "version" => Ok(format!("openpxe {}", env!("CARGO_PKG_VERSION"))),
"uptime" => Ok(uptime_string(state)), "uptime" => Ok(uptime_string(state)),
"status" => Ok(status_text(state)), "status" => Ok(status_text(state)),
"isos" | "images" => Ok(isos_text(state)), "isos" | "images" => Ok(isos_text(state)),
"clients" => Ok(clients_text(state)), "clients" => Ok(clients_text(state)),
"gate" => gate_command(state, tail).await, "queue" => queue_command(state, tail).await,
"nfs" => nfs_command(state, tail).await, // `smb` controls the outbound Samba server for Windows
// install media. `share` lists/manages remote SMB shares
// OpenPXE pulls ISOs from (v0.4.65). `nfs` is the parallel
// command for remote NFSv3 shares (v0.4.67, in-process via
// nfs3_client — not the v0.4.64 kernel-mount path).
"share" | "smb-share" => smb_share_command(state, tail).await,
"smb" => smb_command(state, tail).await, "smb" => smb_command(state, tail).await,
"nfs" => nfs_share_command(state, tail).await,
"log" => log_command(state, tail), "log" => log_command(state, tail),
"whoami" => Ok("operator".to_string()), "whoami" => Ok("operator".to_string()),
"echo" => Ok(tail.join(" ")), "echo" => Ok(tail.join(" ")),
@@ -96,32 +111,52 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
fn status_text(s: &AppState) -> String { fn status_text(s: &AppState) -> String {
let isos = s.iso_store.list(); let isos = s.iso_store.list();
let clients = s.clients.list(); let clients = s.clients.list();
let gates = s.gates.list(); let queue_entries = s.queue.list();
let smb = s.smb.as_ref().map(|m| m.snapshot()); let smb = s.smb.as_ref().map(|m| m.snapshot());
let nfs = s.nfs.list(); let smb_shares = s.smb_shares.list();
let nfs_active = nfs.iter().filter(|m| m.mounted).count(); let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count();
// v0.4.67: NFSv3 sources too.
let nfs_shares = s.nfs_shares.list();
let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count();
format!( format!(
"PXEForge {ver}\n\ "OpenPXE {ver}\n\
base url: {base}\n\ base url: {base}\n\
interface: {nic}\n\ interface: {nic}\n\
uptime: {up}\n\ uptime: {up}\n\
isos: {n_isos} (local: {n_local}, nfs: {n_nfs})\n\ isos: {n_isos} (local: {n_local}, smb: {n_smb}, nfs: {n_nfs})\n\
clients: {n_clients}\n\ clients: {n_clients}\n\
gates: {n_gates}\n\ queue: {n_entries}\n\
smb: {smb}\n\ smb server: {smb}\n\
nfs mounts: {n_total} configured ({n_active} active)\n", smb shares: {n_smb_total} configured ({n_smb_active} reachable)\n\
nfs shares: {n_nfs_total} configured ({n_nfs_active} reachable)\n",
ver = env!("CARGO_PKG_VERSION"), ver = env!("CARGO_PKG_VERSION"),
base = s.public_base_url, base = s.public_base_url,
nic = if s.nic_name.is_empty() { "?" } else { s.nic_name.as_str() }, nic = if s.nic_name.is_empty() {
"?"
} else {
s.nic_name.as_str()
},
up = uptime_string(s), up = uptime_string(s),
n_isos = isos.len(), n_isos = isos.len(),
n_local = isos.iter().filter(|i| matches!(i.source, pxeforge_iso_store::IsoSource::Local)).count(), n_local = isos
n_nfs = isos.iter().filter(|i| !matches!(i.source, pxeforge_iso_store::IsoSource::Local)).count(), .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::Nfs { .. }))
.count(),
n_clients = clients.len(), n_clients = clients.len(),
n_gates = gates.len(), n_entries = queue_entries.len(),
smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")), smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")),
n_total = nfs.len(), n_smb_total = smb_shares.len(),
n_active = nfs_active, n_smb_active = smb_reachable,
n_nfs_total = nfs_shares.len(),
n_nfs_active = nfs_reachable,
) )
} }
@@ -138,8 +173,10 @@ fn isos_text(s: &AppState) -> String {
); );
for i in isos { for i in isos {
let src = match i.source { let src = match i.source {
pxeforge_iso_store::IsoSource::Local => "local".to_string(), openpxe_iso_store::IsoSource::Local => "local".to_string(),
pxeforge_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!( let _ = writeln!(
out, out,
@@ -159,11 +196,7 @@ fn clients_text(s: &AppState) -> String {
return "(no clients yet)".into(); return "(no clients yet)".into();
} }
let mut out = String::new(); let mut out = String::new();
let _ = writeln!( let _ = writeln!(out, "{:<19} {:<16} {:<8} LAST SEEN", "MAC", "IP", "EVENTS");
out,
"{:<19} {:<16} {:<8} {}",
"MAC", "IP", "EVENTS", "LAST SEEN"
);
for c in clients { for c in clients {
let ip = c.last_ip.map_or_else(|| "-".into(), |i| i.to_string()); let ip = c.last_ip.map_or_else(|| "-".into(), |i| i.to_string());
let _ = writeln!( let _ = writeln!(
@@ -180,35 +213,35 @@ fn clients_text(s: &AppState) -> String {
out out
} }
// ── gate ────────────────────────────────────────────────────────────── // ── queue ──────────────────────────────────────────────────────────────
// `async` for symmetry with the other dispatch helpers — gate operations // `async` for symmetry with the other dispatch helpers — queue operations
// are sync today but might grow to await on a database in a future phase. // are sync today but might grow to await on a database in a future phase.
#[allow(clippy::unused_async)] #[allow(clippy::unused_async)]
async fn gate_command(s: &AppState, args: &[String]) -> Result<String, String> { async fn queue_command(s: &AppState, args: &[String]) -> Result<String, String> {
match args.first().map(String::as_str) { match args.first().map(String::as_str) {
None | Some("list") => { None | Some("list") => {
let gs = s.gates.list(); let entries = s.queue.list();
if gs.is_empty() { if entries.is_empty() {
return Ok("(no gates)".into()); return Ok("(queue empty)".into());
} }
let mut out = String::new(); let mut out = String::new();
for g in gs { for entry in entries {
let _ = writeln!( let _ = writeln!(
out, out,
"#{:<3} {:<19} {:<16} target={}", "#{:<3} {:<19} {:<16} target={}",
g.position, entry.position,
g.mac, entry.mac,
g.id, entry.id,
g.assigned_target.unwrap_or_else(|| "-".into()) entry.assigned_target.unwrap_or_else(|| "-".into())
); );
} }
Ok(out) Ok(out)
} }
Some("assign-all") => { Some("assign-all") => {
let target = args.get(1).ok_or_else(|| { let target = args
"usage: gate assign-all <iso_boot_entry_id>".to_string() .get(1)
})?; .ok_or_else(|| "usage: queue assign-all <iso_boot_entry_id>".to_string())?;
let found = s let found = s
.iso_store .iso_store
.list() .list()
@@ -217,61 +250,168 @@ async fn gate_command(s: &AppState, args: &[String]) -> Result<String, String> {
if !found { if !found {
return Err(format!("no such boot entry: {target}")); return Err(format!("no such boot entry: {target}"));
} }
let ids: Vec<_> = s.gates.list().into_iter().map(|g| g.id).collect(); let ids: Vec<_> = s.queue.list().into_iter().map(|g| g.id).collect();
let n = s.gates.assign(&ids, target); let n = s.queue.assign(&ids, target);
Ok(format!("assigned {n} gates -> {target}")) Ok(format!("assigned {n} queue entries -> {target}"))
} }
Some("assign") => { Some("assign") => {
let gate_id = args let entry_id = args
.get(1) .get(1)
.ok_or_else(|| "usage: gate assign <gate_id> <iso_boot_entry_id>".to_string())?; .ok_or_else(|| "usage: queue assign <entry_id> <iso_boot_entry_id>".to_string())?;
let target = args let target = args
.get(2) .get(2)
.ok_or_else(|| "usage: gate assign <gate_id> <iso_boot_entry_id>".to_string())?; .ok_or_else(|| "usage: queue assign <entry_id> <iso_boot_entry_id>".to_string())?;
let n = s.gates.assign(std::slice::from_ref(gate_id), target); let n = s.queue.assign(std::slice::from_ref(entry_id), target);
if n == 0 { if n == 0 {
return Err(format!("no such gate: {gate_id}")); return Err(format!("no such queue entry: {entry_id}"));
} }
Ok(format!("assigned 1 gate -> {target}")) Ok(format!("assigned 1 queue entry -> {target}"))
} }
Some("release") => { Some("release") => {
let gate_id = args.get(1).ok_or_else(|| "usage: gate release <gate_id>".to_string())?; let entry_id = args
match s.gates.release(gate_id) { .get(1)
Some(_) => Ok(format!("released {gate_id}")), .ok_or_else(|| "usage: queue release <entry_id>".to_string())?;
None => Err(format!("no such gate: {gate_id}")), match s.queue.release(entry_id) {
Some(_) => Ok(format!("released {entry_id}")),
None => Err(format!("no such queue entry: {entry_id}")),
} }
} }
Some(other) => Err(format!( Some(other) => Err(format!(
"unknown gate subcommand: {other}\ntry: gate [list|assign-all|assign|release]" "unknown queue subcommand: {other}\ntry: queue [list|assign-all|assign|release]"
)), )),
} }
} }
// ── nfs ──────────────────────────────────────────────────────────────── // ── share (v0.4.65: SMB shares) ─────────────────────────────────────────
async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> { async fn smb_share_command(s: &AppState, args: &[String]) -> Result<String, String> {
match args.first().map(String::as_str) { match args.first().map(String::as_str) {
None | Some("list") => { None | Some("list") => {
let mounts = s.nfs.list(); let shares = s.smb_shares.list();
if mounts.is_empty() { if shares.is_empty() {
return Ok("(no NFS mounts configured)".into()); return Ok("(no SMB shares configured)".into());
} }
let mut out = String::new(); let mut out = String::new();
let _ = writeln!( let _ = writeln!(
out, out,
"{:<24} {:<6} {:<7} {:<6} {}", "{:<24} {:<7} {:<6} {:<6} TARGET",
"ID", "VER", "STATUS", "ISOS", "TARGET" "ID", "STATUS", "AUTH", "ISOS"
); );
for m in mounts { for m in shares {
let status = if m.mounted { "ok" } else { "down" }; let status = if m.reachable { "ok" } else { "down" };
let auth = if m.guest { "guest" } else { "user" };
let _ = writeln!( let _ = writeln!(
out, out,
"{:<24} {:<6} {:<7} {:<6} {}:{}", "{:<24} {:<7} {:<6} {:<6} //{}/{}",
truncate(&m.id, 24),
status,
auth,
m.iso_count,
m.server,
m.share,
);
if let Some(e) = m.last_error {
let _ = writeln!(out, " error: {e}");
}
if let Some(h) = m.last_hint {
let _ = writeln!(out, " hint: {h}");
}
}
Ok(out)
}
Some("add") => {
// share add //server/share [guest|user:password]
let target = args
.get(1)
.ok_or_else(|| {
"usage: share add //server/share [guest|user:password]".to_string()
})?;
// Accept either `//server/share` (UNC-style) or
// `server:share` (shorter to type).
let stripped = target.trim_start_matches('/').trim_start_matches('\\');
let (server, share) = if let Some((s, p)) = stripped.split_once('/') {
(s, p)
} else if let Some((s, p)) = stripped.split_once(':') {
(s, p)
} else {
return Err("target must be '//server/share' or 'server:share'".into());
};
// Auth spec: "guest" or "user:password". Default: guest.
let auth = args.get(2).cloned().unwrap_or_else(|| "guest".into());
let (guest, username, password) = if auth == "guest" {
(true, None, None)
} else if let Some((u, p)) = auth.split_once(':') {
(false, Some(u.to_string()), Some(p.to_string()))
} else {
return Err("auth must be 'guest' or 'user:password'".into());
};
let req = openpxe_iso_store::SmbAddRequest {
server: server.to_string(),
share: share.to_string(),
username,
password,
guest,
port: None,
};
match s.smb_shares.add(req).await {
Ok(m) => Ok(format!("added {} ({} isos)", m.id, m.iso_count)),
Err(e) => {
let mut out = format!("add failed: {}", e.error);
if let Some(h) = e.hint {
out.push_str("\nhint: ");
out.push_str(&h);
}
Err(out)
}
}
}
Some("remove") => {
let id = args
.get(1)
.ok_or_else(|| "usage: share remove <id>".to_string())?;
match s.smb_shares.remove(id).await {
Ok(()) => Ok(format!("removed {id}")),
Err(e) => Err(format!("remove failed: {e}")),
}
}
Some("scan") => {
let id = args
.get(1)
.ok_or_else(|| "usage: share scan <id>".to_string())?;
match s.smb_shares.rescan(id).await {
Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
Err(e) => Err(format!("scan failed: {e}")),
}
}
Some(other) => Err(format!(
"unknown share subcommand: {other}\ntry: share [list|add|remove|scan]"
)),
}
}
// ── nfs (v0.4.67: in-process NFSv3 via nfs3_client) ────────────────────
async fn nfs_share_command(s: &AppState, args: &[String]) -> Result<String, String> {
match args.first().map(String::as_str) {
None | Some("list") => {
let shares = s.nfs_shares.list();
if shares.is_empty() {
return Ok("(no NFS shares configured)".into());
}
let mut out = String::new();
let _ = writeln!(
out,
"{:<24} {:<7} {:<6} TARGET",
"ID", "STATUS", "ISOS"
);
for m in shares {
let status = if m.reachable { "ok" } else { "down" };
let _ = writeln!(
out,
"{:<24} {:<7} {:<6} {}:{}",
truncate(&m.id, 24), truncate(&m.id, 24),
match m.version {
pxeforge_iso_store::NfsVersion::V3 => "v3",
pxeforge_iso_store::NfsVersion::V41 => "v4.1",
},
status, status,
m.iso_count, m.iso_count,
m.server, m.server,
@@ -280,50 +420,58 @@ async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
if let Some(e) = m.last_error { if let Some(e) = m.last_error {
let _ = writeln!(out, " error: {e}"); let _ = writeln!(out, " error: {e}");
} }
if let Some(h) = m.last_hint {
let _ = writeln!(out, " hint: {h}");
}
} }
Ok(out) Ok(out)
} }
Some("mount") => { Some("add") => {
// nfs mount <server>:<export> [v3|v41] [ro|rw] // nfs add <server>:<export> [port]
let target = args let target = args
.get(1) .get(1)
.ok_or_else(|| "usage: nfs mount <server>:<export> [v3|v41] [ro|rw]".to_string())?; .ok_or_else(|| "usage: nfs add <server>:<export> [port]".to_string())?;
let (server, export) = target let (server, export) = target
.split_once(':') .split_once(':')
.ok_or_else(|| "target must be 'server:/export'".to_string())?; .ok_or_else(|| "target must be 'server:/export'".to_string())?;
let version = match args.get(2).map(String::as_str) { let port = args.get(2).and_then(|s| s.parse::<u16>().ok());
Some("v3") => pxeforge_iso_store::NfsVersion::V3, let req = openpxe_iso_store::NfsAddRequest {
Some("v41") | None => pxeforge_iso_store::NfsVersion::V41,
Some(other) => return Err(format!("unknown nfs version: {other} (expect v3 or v41)")),
};
let read_only = !matches!(args.get(3).map(String::as_str), Some("rw"));
let req = pxeforge_iso_store::NfsAddRequest {
server: server.to_string(), server: server.to_string(),
export: export.to_string(), export: export.to_string(),
version, port,
read_only,
}; };
match s.nfs.add(req).await { match s.nfs_shares.add(req).await {
Ok(m) => Ok(format!("mounted {} ({} isos)", m.id, m.iso_count)), Ok(m) => Ok(format!("added {} ({} isos)", m.id, m.iso_count)),
Err(e) => Err(format!("mount failed: {e}")), Err(e) => {
let mut out = format!("add failed: {}", e.error);
if let Some(h) = e.hint {
out.push_str("\nhint: ");
out.push_str(&h);
}
Err(out)
} }
} }
Some("unmount") => { }
let id = args.get(1).ok_or_else(|| "usage: nfs unmount <id>".to_string())?; Some("remove") => {
match s.nfs.remove(id).await { let id = args
Ok(()) => Ok(format!("unmounted {id}")), .get(1)
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") => { Some("scan") => {
let id = args.get(1).ok_or_else(|| "usage: nfs scan <id>".to_string())?; let id = args
match s.nfs.rescan(id).await { .get(1)
.ok_or_else(|| "usage: nfs scan <id>".to_string())?;
match s.nfs_shares.rescan(id).await {
Ok(n) => Ok(format!("re-scanned {id}: {n} isos")), Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
Err(e) => Err(format!("scan failed: {e}")), Err(e) => Err(format!("scan failed: {e}")),
} }
} }
Some(other) => Err(format!( Some(other) => Err(format!(
"unknown nfs subcommand: {other}\ntry: nfs [list|mount|unmount|scan]" "unknown nfs subcommand: {other}\ntry: nfs [list|add|remove|scan]"
)), )),
} }
} }
@@ -368,10 +516,7 @@ fn log_command(s: &AppState, args: &[String]) -> Result<String, String> {
Ok("log buffer cleared".into()) Ok("log buffer cleared".into())
} }
Some("tail") => { Some("tail") => {
let n: usize = args let n: usize = args.get(1).and_then(|v| v.parse().ok()).unwrap_or(20);
.get(1)
.and_then(|v| v.parse().ok())
.unwrap_or(20);
let lines = s.log_bus.recent(); let lines = s.log_bus.recent();
let start = lines.len().saturating_sub(n); let start = lines.len().saturating_sub(n);
let mut out = String::new(); let mut out = String::new();
@@ -453,7 +598,7 @@ pub fn shell_split(input: &str) -> Vec<String> {
} }
const HELP_TEXT: &str = "\ const HELP_TEXT: &str = "\
PXEForge terminal — available commands: OpenPXE terminal — available commands:
help show this help help show this help
version print server version version print server version
@@ -462,18 +607,23 @@ PXEForge terminal — available commands:
isos list registered ISOs isos list registered ISOs
clients list PXE clients seen this session clients list PXE clients seen this session
gate list list gated-deployment queue queue list list queued clients
gate assign <gate_id> <target> assign one gate to a boot entry queue assign <entry_id> <target> assign one queued client to a boot entry
gate assign-all <target> assign every waiting gate queue assign-all <target> assign every waiting client
gate release <gate_id> release one gate queue release <entry_id> release one queued client
nfs list list NFS mounts share list list configured SMB shares
nfs mount <s>:<e> [v3|v41] [ro|rw] add and mount an NFS share share add //srv/share [auth] add an SMB share; auth = 'guest' or 'user:pass'
nfs unmount <id> unmount and forget a share share remove <id> forget an SMB share
nfs scan <id> re-scan a share for new ISOs share scan <id> re-list a share for new ISOs
smb status SMB (Samba) state nfs list list configured NFSv3 shares
smb start | stop | reload control smbd nfs add <srv>:<export> [port] add an NFSv3 share
nfs remove <id> forget an NFS share
nfs scan <id> re-list an NFS share for new ISOs
smb status outbound Samba state (Windows install media)
smb start | stop | reload control the outbound smbd
log clear drop the in-memory log ring buffer log clear drop the in-memory log ring buffer
log tail [n] show the last n buffered lines (default 20) log tail [n] show the last n buffered lines (default 20)
@@ -488,10 +638,10 @@ mod tests {
#[test] #[test]
fn shell_split_basic() { fn shell_split_basic() {
assert_eq!(shell_split(""), Vec::<String>::new()); assert_eq!(shell_split(""), Vec::<String>::new());
assert_eq!(shell_split("nfs list"), vec!["nfs", "list"]); assert_eq!(shell_split("share list"), vec!["share", "list"]);
assert_eq!( assert_eq!(
shell_split("nfs mount 10.0.0.5:/srv v41 ro"), shell_split("share add //nas/isos guest"),
vec!["nfs", "mount", "10.0.0.5:/srv", "v41", "ro"] vec!["share", "add", "//nas/isos", "guest"]
); );
} }
@@ -521,4 +671,10 @@ mod tests {
assert_eq!(truncate("hi", 10), "hi"); assert_eq!(truncate("hi", 10), "hi");
assert_eq!(truncate("longerthanfive", 5), "long…"); assert_eq!(truncate("longerthanfive", 5), "long…");
} }
#[test]
fn help_uses_queue_language() {
assert!(HELP_TEXT.contains("queue list"));
assert!(HELP_TEXT.contains("queued clients"));
}
} }
+180
View File
@@ -0,0 +1,180 @@
//! Chunked upload sessions for browser-driven ISO uploads.
//!
//! The legacy multipart endpoint still exists for simple API clients, but
//! browsers get a better failure mode with raw chunks: progress advances after
//! each acknowledged write, partial files appear in the ISO directory
//! immediately, and reverse proxies are less likely to buffer an entire DVD
//! image before OpenPXE sees byte one.
use bytes::Bytes;
use openpxe_core::{Error, Result};
use openpxe_iso_store::{IsoMeta, IsoStore, UploadHandle};
use serde::Serialize;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use uuid::Uuid;
const DEFAULT_CHUNK_SIZE: u64 = 8 * 1024 * 1024;
#[derive(Clone, Default)]
pub struct UploadSessions {
inner: Arc<Mutex<HashMap<String, Arc<Mutex<UploadSession>>>>>,
}
struct UploadSession {
filename: String,
expected_size: Option<u64>,
offset: u64,
handle: Option<UploadHandle>,
}
#[derive(Debug, Clone, Serialize)]
pub struct UploadStarted {
pub upload_id: String,
pub iso_id: String,
pub filename: String,
pub offset: u64,
pub chunk_size: u64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum UploadAppend {
Progress { offset: u64 },
Complete { offset: u64, iso: Box<IsoMeta> },
}
impl UploadSessions {
pub async fn begin(
&self,
store: &IsoStore,
filename: &str,
expected_size: Option<u64>,
) -> Result<UploadStarted> {
if !filename.to_ascii_lowercase().ends_with(".iso") {
return Err(Error::Invalid("only .iso uploads accepted".to_string()));
}
let handle = store.begin_upload(filename).await?;
let iso_id = handle.id.clone();
let upload_id = Uuid::new_v4().to_string();
let session = UploadSession {
filename: filename.to_string(),
expected_size,
offset: 0,
handle: Some(handle),
};
self.inner
.lock()
.await
.insert(upload_id.clone(), Arc::new(Mutex::new(session)));
Ok(UploadStarted {
upload_id,
iso_id,
filename: filename.to_string(),
offset: 0,
chunk_size: DEFAULT_CHUNK_SIZE,
})
}
pub async fn append(
&self,
store: &IsoStore,
upload_id: &str,
offset: u64,
chunk: Bytes,
complete: bool,
) -> Result<UploadAppend> {
let Some(session_lock) = self.inner.lock().await.get(upload_id).cloned() else {
return Err(Error::Invalid(format!("no such upload '{upload_id}'")));
};
let mut session = session_lock.lock().await;
if session.offset != offset {
return Err(Error::Invalid(format!(
"expected offset {}, got {offset}",
session.offset
)));
}
let new_offset = session
.offset
.checked_add(chunk.len() as u64)
.ok_or_else(|| Error::Invalid("upload offset overflow".to_string()))?;
if let Some(expected) = session.expected_size {
if new_offset > expected {
return Err(Error::Invalid(format!(
"chunk exceeds declared upload size {expected}"
)));
}
}
let Some(handle) = session.handle.as_mut() else {
return Err(Error::Invalid("upload already completed".to_string()));
};
if let Err(e) = handle.write_chunk(&chunk).await {
let handle = session.handle.take();
drop(session);
self.inner.lock().await.remove(upload_id);
if let Some(handle) = handle {
let _ = handle.abort().await;
}
return Err(e);
}
session.offset = new_offset;
if !complete {
return Ok(UploadAppend::Progress { offset: new_offset });
}
if let Some(expected) = session.expected_size {
if new_offset != expected {
return Err(Error::Invalid(format!(
"final chunk ended at {new_offset}, expected {expected}"
)));
}
}
let Some(handle) = session.handle.take() else {
return Err(Error::Invalid("upload already completed".to_string()));
};
let filename = session.filename.clone();
drop(session);
tracing::info!(
target: "openpxe::http::upload",
upload_id,
filename = %filename,
received_bytes = new_offset,
"chunked upload body complete; introspecting"
);
let meta = match handle.finish(store).await {
Ok(meta) => meta,
Err(e) => {
self.inner.lock().await.remove(upload_id);
return Err(e);
}
};
self.inner.lock().await.remove(upload_id);
Ok(UploadAppend::Complete {
offset: new_offset,
iso: Box::new(meta),
})
}
pub async fn abort(&self, upload_id: &str) -> Result<()> {
let Some(session_lock) = self.inner.lock().await.remove(upload_id) else {
return Err(Error::Invalid(format!("no such upload '{upload_id}'")));
};
let mut session = session_lock.lock().await;
if let Some(handle) = session.handle.take() {
handle.abort().await?;
}
Ok(())
}
}
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,16 +1,16 @@
[package] [package]
name = "pxeforge-ipxe-assets" name = "openpxe-ipxe-assets"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
description = "Bundled iPXE binaries and default chain scripts for PXEForge" description = "Bundled iPXE binaries and default chain scripts for OpenPXE"
[lints] [lints]
workspace = true workspace = true
[dependencies] [dependencies]
pxeforge-core.workspace = true openpxe-core.workspace = true
rust-embed.workspace = true rust-embed.workspace = true
tracing.workspace = true tracing.workspace = true
thiserror.workspace = true thiserror.workspace = true
+15 -5
View File
@@ -1,7 +1,7 @@
//! Bundled iPXE boot binaries and default chain script. //! Bundled iPXE boot binaries and default chain script.
//! //!
//! At build time, we expect the iPXE binaries to live at `assets/ipxe/` at //! At build time, we expect the iPXE binaries to live at `assets/ipxe/` at
//! the workspace root. They are embedded into the PXEForge binary via //! the workspace root. They are embedded into the OpenPXE binary via
//! `rust-embed` so the container image is self-contained. If a binary is //! `rust-embed` so the container image is self-contained. If a binary is
//! missing, that architecture simply won't have PXE support — we log at //! missing, that architecture simply won't have PXE support — we log at
//! startup and serve what we have. //! startup and serve what we have.
@@ -16,7 +16,7 @@
//! - `wimboot` — Windows boot shim (fetched separately for WIM chains) //! - `wimboot` — Windows boot shim (fetched separately for WIM chains)
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
use pxeforge_core::ClientArch; use openpxe_core::ClientArch;
use rust_embed::Embed; use rust_embed::Embed;
#[derive(Embed)] #[derive(Embed)]
@@ -40,10 +40,20 @@ pub fn asset_bytes(name: &str) -> Option<Vec<u8>> {
IpxeAssets::get(name).map(|f| f.data.into_owned()) IpxeAssets::get(name).map(|f| f.data.into_owned())
} }
/// Same as [`asset_bytes`] but returns the embedded slice directly,
/// avoiding the heap copy when the caller only needs to read the
/// payload. Falls back to None for unknown names.
#[must_use]
pub fn asset_slice(name: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
IpxeAssets::get(name).map(|f| f.data)
}
/// Enumerate embedded asset filenames. Useful for startup logging so the /// Enumerate embedded asset filenames. Useful for startup logging so the
/// operator can immediately tell which architectures will work. /// operator can immediately tell which architectures will work.
pub fn list_assets() -> Vec<String> { pub fn list_assets() -> Vec<String> {
IpxeAssets::iter().map(|c| c.into_owned()).collect() IpxeAssets::iter()
.map(std::borrow::Cow::into_owned)
.collect()
} }
/// Log at startup which iPXE binaries are present and which are missing. /// Log at startup which iPXE binaries are present and which are missing.
@@ -58,10 +68,10 @@ pub fn log_availability() {
]; ];
for (arch, name) in needed { for (arch, name) in needed {
if have.contains(name) { if have.contains(name) {
tracing::info!(target: "pxeforge::ipxe", "bundled iPXE for {}: {}", arch.as_str(), name); tracing::info!(target: "openpxe::ipxe", "bundled iPXE for {}: {}", arch.as_str(), name);
} else { } else {
tracing::warn!( tracing::warn!(
target: "pxeforge::ipxe", target: "openpxe::ipxe",
"MISSING iPXE binary for {}: {} — clients of this arch will not PXE boot", "MISSING iPXE binary for {}: {} — clients of this arch will not PXE boot",
arch.as_str(), name arch.as_str(), name
); );
+19 -3
View File
@@ -1,16 +1,16 @@
[package] [package]
name = "pxeforge-iso-store" name = "openpxe-iso-store"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
description = "ISO upload, storage, introspection, and boot-entry generation for PXEForge" description = "ISO upload, storage, introspection, and boot-entry generation for OpenPXE"
[lints] [lints]
workspace = true workspace = true
[dependencies] [dependencies]
pxeforge-core.workspace = true openpxe-core.workspace = true
tokio = { workspace = true } tokio = { workspace = true }
tokio-util = { workspace = true } tokio-util = { workspace = true }
serde.workspace = true serde.workspace = true
@@ -20,12 +20,28 @@ thiserror.workspace = true
anyhow.workspace = true anyhow.workspace = true
sha2.workspace = true sha2.workspace = true
hex.workspace = true hex.workspace = true
bcrypt.workspace = true
uuid.workspace = true uuid.workspace = true
time.workspace = true time.workspace = true
parking_lot.workspace = true parking_lot.workspace = true
bytes.workspace = true bytes.workspace = true
tempfile = "3.12" tempfile = "3.12"
libc = "0.2" libc = "0.2"
# v0.4.61: server-side compose of the operator's uploaded raster into a
# fixed 1024x768 canvas so the PXE menu always gets a consistently-sized
# PNG regardless of what the operator uploaded. We use the bare-bones
# `image` crate (no default features) and explicitly enable only the
# decoders we accept on upload (PNG/JPEG/WebP/GIF) plus the PNG
# 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] [dev-dependencies]
tempfile = "3.12" tempfile = "3.12"
+40 -13
View File
@@ -49,7 +49,7 @@ pub fn introspect(path: &Path) -> IntrospectionReport {
}; };
let Ok(mut f) = std::fs::File::open(path) else { let Ok(mut f) = std::fs::File::open(path) else {
tracing::warn!(target: "pxeforge::iso", "cannot open ISO for introspection: {}", path.display()); tracing::warn!(target: "openpxe::iso", "cannot open ISO for introspection: {}", path.display());
return report; return report;
}; };
@@ -78,7 +78,9 @@ pub fn introspect(path: &Path) -> IntrospectionReport {
let mut haystack = Vec::with_capacity(scan_bytes.min(32 * 1024 * 1024)); let mut haystack = Vec::with_capacity(scan_bytes.min(32 * 1024 * 1024));
while read_total < scan_bytes { while read_total < scan_bytes {
let n = f.read(&mut buf).unwrap_or(0); let n = f.read(&mut buf).unwrap_or(0);
if n == 0 { break; } if n == 0 {
break;
}
haystack.extend_from_slice(&buf[..n]); haystack.extend_from_slice(&buf[..n]);
read_total += n; read_total += n;
} }
@@ -98,7 +100,7 @@ pub fn introspect(path: &Path) -> IntrospectionReport {
// that happens in the store after introspection. // that happens in the store after introspection.
let (k, i) = guess_kernel_initrd(report.family); let (k, i) = guess_kernel_initrd(report.family);
report.kernel_path = k.map(str::to_string); report.kernel_path = k.map(str::to_string);
report.initrd_paths = i.iter().map(|s| s.to_string()).collect(); report.initrd_paths = i.iter().map(std::string::ToString::to_string).collect();
report report
} }
@@ -107,8 +109,11 @@ fn family_from_label(label: &str) -> DistroFamily {
let l = label.to_ascii_lowercase(); let l = label.to_ascii_lowercase();
if l.contains("ubuntu") || l.contains("debian") || l.contains("mint") { if l.contains("ubuntu") || l.contains("debian") || l.contains("mint") {
DistroFamily::DebianUbuntu DistroFamily::DebianUbuntu
} else if l.contains("rhel") || l.contains("centos") || l.contains("fedora") } else if l.contains("rhel")
|| l.contains("rocky") || l.contains("alma") || l.contains("centos")
|| l.contains("fedora")
|| l.contains("rocky")
|| l.contains("alma")
{ {
DistroFamily::RhelFedora DistroFamily::RhelFedora
} else if l.contains("suse") || l.contains("opensuse") { } else if l.contains("suse") || l.contains("opensuse") {
@@ -127,17 +132,30 @@ fn family_from_label(label: &str) -> DistroFamily {
fn guess_kernel_initrd(family: DistroFamily) -> (Option<&'static str>, Vec<&'static str>) { fn guess_kernel_initrd(family: DistroFamily) -> (Option<&'static str>, Vec<&'static str>) {
match family { match family {
DistroFamily::DebianUbuntu => (Some("/casper/vmlinuz"), vec!["/casper/initrd"]), DistroFamily::DebianUbuntu => (Some("/casper/vmlinuz"), vec!["/casper/initrd"]),
DistroFamily::RhelFedora => (Some("/images/pxeboot/vmlinuz"), vec!["/images/pxeboot/initrd.img"]), DistroFamily::RhelFedora => (
DistroFamily::OpenSuse => (Some("/boot/x86_64/loader/linux"), vec!["/boot/x86_64/loader/initrd"]), Some("/images/pxeboot/vmlinuz"),
DistroFamily::Arch => (Some("/arch/boot/x86_64/vmlinuz-linux"), vec!["/arch/boot/x86_64/initramfs-linux.img"]), vec!["/images/pxeboot/initrd.img"],
),
DistroFamily::OpenSuse => (
Some("/boot/x86_64/loader/linux"),
vec!["/boot/x86_64/loader/initrd"],
),
DistroFamily::Arch => (
Some("/arch/boot/x86_64/vmlinuz-linux"),
vec!["/arch/boot/x86_64/initramfs-linux.img"],
),
DistroFamily::Alpine => (Some("/boot/vmlinuz-lts"), vec!["/boot/initramfs-lts"]), DistroFamily::Alpine => (Some("/boot/vmlinuz-lts"), vec!["/boot/initramfs-lts"]),
DistroFamily::WindowsPe | DistroFamily::Unknown => (None, Vec::new()), DistroFamily::WindowsPe | DistroFamily::Unknown => (None, Vec::new()),
} }
} }
fn contains_ascii(haystack: &[u8], needle: &[u8]) -> bool { fn contains_ascii(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || haystack.len() < needle.len() { return false; } if needle.is_empty() || haystack.len() < needle.len() {
haystack.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle)) return false;
}
haystack
.windows(needle.len())
.any(|w| w.eq_ignore_ascii_case(needle))
} }
#[cfg(test)] #[cfg(test)]
@@ -146,9 +164,18 @@ mod tests {
#[test] #[test]
fn label_matching() { fn label_matching() {
assert_eq!(family_from_label("Ubuntu 24.04"), DistroFamily::DebianUbuntu); assert_eq!(
assert_eq!(family_from_label("Rocky-9-x86_64-dvd"), DistroFamily::RhelFedora); family_from_label("Ubuntu 24.04"),
assert_eq!(family_from_label("openSUSE-Leap-15.6"), DistroFamily::OpenSuse); DistroFamily::DebianUbuntu
);
assert_eq!(
family_from_label("Rocky-9-x86_64-dvd"),
DistroFamily::RhelFedora
);
assert_eq!(
family_from_label("openSUSE-Leap-15.6"),
DistroFamily::OpenSuse
);
assert_eq!(family_from_label("ARCH_202604"), DistroFamily::Arch); assert_eq!(family_from_label("ARCH_202604"), DistroFamily::Arch);
assert_eq!(family_from_label("weird-custom"), DistroFamily::Unknown); assert_eq!(family_from_label("weird-custom"), DistroFamily::Unknown);
} }
+22 -3
View File
@@ -18,14 +18,33 @@
pub mod entry; pub mod entry;
pub mod introspect; pub mod introspect;
pub mod nfs; pub mod nfs_share;
pub mod pxe_logo;
pub mod smb; pub mod smb;
pub mod smb_share;
pub mod store; pub mod store;
pub mod unattended;
pub mod windows; pub mod windows;
pub use entry::{BootEntry, BootKind, KernelArgs}; pub use entry::{BootEntry, BootKind, KernelArgs};
pub use introspect::{DistroFamily, IntrospectionReport}; pub use introspect::{DistroFamily, IntrospectionReport};
pub use nfs::{NfsAddRequest, NfsManager, NfsMount, NfsVersion}; // v0.4.65: kernel-mount NFS is gone. SMB shares via Samba's userspace
// `smbclient` CLI replaced it — works in any container (no
// CAP_SYS_ADMIN, no host kernel modules), matching how Bootimus and
// every other PXE/imaging tool that supports network storage handles
// it.
pub use smb::{extract_windows_iso, SmbManager, SmbState}; pub use smb::{extract_windows_iso, SmbManager, SmbState};
pub use store::{generate_boot_entries_for, slugify_str, IsoMeta, IsoSource, IsoStore, UploadHandle}; 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,
};
pub use unattended::{
classify as classify_unattended, render_template, UnattendedKind, UnattendedMeta,
UnattendedStore, MAX_UNATTENDED_BYTES,
};
pub use windows::{WimPatcher, WinPatchState}; pub use windows::{WimPatcher, WinPatchState};
-564
View File
@@ -1,564 +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 parking_lot::Mutex;
use pxeforge_core::{Error, Result};
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 PXEForge 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: "pxeforge::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: "pxeforge::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: "pxeforge::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: "pxeforge::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: "pxeforge::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: "pxeforge::nfs", "write NFS state tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, path) {
tracing::warn!(target: "pxeforge::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
+264
View File
@@ -0,0 +1,264 @@
//! PXE boot-menu background compositor.
//!
//! 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:
//!
//! - 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.
//!
//! 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.
//!
//! 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. 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;
/// 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 from the canvas top to the logo's top edge.
const LOGO_TOP_MARGIN: u32 = 72;
/// 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 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);
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(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;
}
img.resize(max_w, max_h, FilterType::Lanczos3)
}
#[cfg(test)]
mod tests {
use super::*;
use image::{ImageBuffer, Rgb};
fn solid_png(w: u32, h: u32, rgb: [u8; 3]) -> Vec<u8> {
let img: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_pixel(w, h, Rgb(rgb));
let mut out = Vec::with_capacity(4096);
DynamicImage::ImageRgb8(img)
.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
.unwrap();
out
}
#[test]
fn custom_logo_emits_canvas_sized_png_with_dark_field() {
let src = solid_png(120, 60, [200, 50, 50]);
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 custom_logo_painted_in_top_band() {
let src = solid_png(100, 40, [10, 200, 10]);
let out = compose_pxe_background(Some(&src)).unwrap();
let canvas = image::load_from_memory(&out).unwrap().to_rgba8();
let cx = (CANVAS_W - 100) / 2;
let cy = LOGO_TOP_MARGIN;
let inside = canvas.get_pixel(cx + 10, cy + 10);
assert!(
inside.0[1] > 100 && inside.0[0] < 100,
"logo pixel color mismatch: {inside:?}"
);
}
#[test]
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();
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_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]);
}
}
+104 -33
View File
@@ -20,16 +20,16 @@
//! - SMB2 minimum (no SMB1 legacy, not needed for WinPE). //! - SMB2 minimum (no SMB1 legacy, not needed for WinPE).
//! - Bound to 0.0.0.0:445; operator MUST put this on a trusted install //! - Bound to 0.0.0.0:445; operator MUST put this on a trusted install
//! VLAN — guest SMB is not for the general internet. //! VLAN — guest SMB is not for the general internet.
//! - smbd runs as the same non-root uid as pxeforge (10001). //! - smbd runs as the same non-root uid as openpxe (10001).
//! - If `smbd` isn't on PATH (e.g. lightweight container build without //! - If `smbd` isn't on PATH (e.g. lightweight container build without
//! Samba), we return `SmbState::SmbdMissing` and the UI surfaces the //! Samba), we return `SmbState::SmbdMissing` and the UI surfaces the
//! gap. No panics, no retries, no silent failure. //! gap. No panics, no retries, no silent failure.
use parking_lot::Mutex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::sync::Arc; use std::sync::Arc;
use parking_lot::Mutex;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", tag = "state")] #[serde(rename_all = "snake_case", tag = "state")]
@@ -72,7 +72,9 @@ impl SmbManager {
/// ISO under `smb_dir/<slug>/` becomes a share named `<slug>`. Returns /// ISO under `smb_dir/<slug>/` becomes a share named `<slug>`. Returns
/// the sorted list. /// the sorted list.
pub fn discover_shares(&self) -> Vec<String> { pub fn discover_shares(&self) -> Vec<String> {
let Ok(rd) = std::fs::read_dir(&self.smb_dir) else { return vec![]; }; let Ok(rd) = std::fs::read_dir(&self.smb_dir) else {
return vec![];
};
let mut out: Vec<String> = rd let mut out: Vec<String> = rd
.flatten() .flatten()
.filter(|e| e.path().is_dir()) .filter(|e| e.path().is_dir())
@@ -87,23 +89,28 @@ impl SmbManager {
/// Write out `smb.conf` for the currently-discovered shares. Safe to /// Write out `smb.conf` for the currently-discovered shares. Safe to
/// call while smbd is running — smbd reloads on SIGHUP. /// call while smbd is running — smbd reloads on SIGHUP.
pub fn write_conf(&self) -> std::io::Result<Vec<String>> { pub fn write_conf(&self) -> std::io::Result<Vec<String>> {
use std::fmt::Write as _;
std::fs::create_dir_all(&self.smb_dir)?; std::fs::create_dir_all(&self.smb_dir)?;
let shares = self.discover_shares(); let shares = self.discover_shares();
let mut conf = String::new(); let mut conf = String::new();
conf.push_str(SMB_CONF_GLOBAL); conf.push_str(SMB_CONF_GLOBAL);
for name in &shares { for name in &shares {
let path = self.smb_dir.join(name); let path = self.smb_dir.join(name);
conf.push_str(&format!( // Per-share block. `write!` to String never fails — the unwrap
// is provably unreachable, but expect() makes that explicit.
write!(
conf,
"\n[{name}]\n\ "\n[{name}]\n\
path = {}\n\ path = {}\n\
comment = PXEForge Windows install media ({name})\n\ comment = OpenPXE Windows install media ({name})\n\
read only = yes\n\ read only = yes\n\
guest ok = yes\n\ guest ok = yes\n\
guest only = yes\n\ guest only = yes\n\
browseable = yes\n\ browseable = yes\n\
available = yes\n", available = yes\n",
path.display(), path.display(),
)); )
.expect("writing to a String is infallible");
} }
let tmp = self.conf_path.with_extension("conf.tmp"); let tmp = self.conf_path.with_extension("conf.tmp");
std::fs::write(&tmp, conf)?; std::fs::write(&tmp, conf)?;
@@ -114,7 +121,7 @@ impl SmbManager {
/// Start smbd. No-op if already running. /// Start smbd. No-op if already running.
pub fn start(&self) -> SmbState { pub fn start(&self) -> SmbState {
let mut g = self.child.lock(); let mut g = self.child.lock();
if g.as_ref().map_or(false, |c| c.id() > 0) { if g.as_ref().is_some_and(|c| c.id() > 0) {
return self.state.lock().clone(); return self.state.lock().clone();
} }
if !smbd_present() { if !smbd_present() {
@@ -125,7 +132,9 @@ impl SmbManager {
let shares = match self.write_conf() { let shares = match self.write_conf() {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
let s = SmbState::Failed { reason: format!("write smb.conf: {e}") }; let s = SmbState::Failed {
reason: format!("write smb.conf: {e}"),
};
*self.state.lock() = s.clone(); *self.state.lock() = s.clone();
return s; return s;
} }
@@ -134,7 +143,8 @@ impl SmbManager {
.args([ .args([
"--foreground", "--foreground",
"--no-process-group", "--no-process-group",
"--configfile", self.conf_path.to_str().unwrap_or(""), "--configfile",
self.conf_path.to_str().unwrap_or(""),
"--log-stdout", "--log-stdout",
]) ])
.stdin(Stdio::null()) .stdin(Stdio::null())
@@ -147,11 +157,13 @@ impl SmbManager {
*g = Some(c); *g = Some(c);
let s = SmbState::Running { pid, shares }; let s = SmbState::Running { pid, shares };
*self.state.lock() = s.clone(); *self.state.lock() = s.clone();
tracing::info!(target: "pxeforge::smb", pid, shares=?self.state.lock(), "smbd started"); tracing::info!(target: "openpxe::smb", pid, shares=?self.state.lock(), "smbd started");
s s
} }
Err(e) => { Err(e) => {
let s = SmbState::Failed { reason: format!("spawn smbd: {e}") }; let s = SmbState::Failed {
reason: format!("spawn smbd: {e}"),
};
*self.state.lock() = s.clone(); *self.state.lock() = s.clone();
s s
} }
@@ -163,17 +175,24 @@ impl SmbManager {
#[allow(unsafe_code)] #[allow(unsafe_code)]
pub fn reconcile(&self) -> SmbState { pub fn reconcile(&self) -> SmbState {
let mut g = self.child.lock(); let mut g = self.child.lock();
if g.is_none() { return self.state.lock().clone(); } if g.is_none() {
return self.state.lock().clone();
}
let shares = match self.write_conf() { let shares = match self.write_conf() {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
let s = SmbState::Failed { reason: format!("write smb.conf: {e}") }; let s = SmbState::Failed {
reason: format!("write smb.conf: {e}"),
};
*self.state.lock() = s.clone(); *self.state.lock() = s.clone();
return s; return s;
} }
}; };
if let Some(c) = g.as_mut() { if let Some(c) = g.as_mut() {
let pid = c.id() as i32; // u32 -> i32 for libc::kill. We never spawn enough children
// for the pid to overflow i32; cast_signed makes the intent
// explicit and silences the lint.
let pid = c.id().cast_signed();
// SAFETY: libc::kill is FFI-safe; we pass a pid we own (returned // SAFETY: libc::kill is FFI-safe; we pass a pid we own (returned
// from `Child::id` above, the child is alive because we hold the // from `Child::id` above, the child is alive because we hold the
// Mutex guard `g`) and a well-defined signal constant. Return // Mutex guard `g`) and a well-defined signal constant. Return
@@ -183,8 +202,13 @@ impl SmbManager {
// covers this is `nix`, which pulls ~40 transitive deps for a // covers this is `nix`, which pulls ~40 transitive deps for a
// single signal send. One documented unsafe call is the better // single signal send. One documented unsafe call is the better
// tradeoff for a container-first project. // tradeoff for a container-first project.
unsafe { libc::kill(pid, libc::SIGHUP); } unsafe {
let s = SmbState::Running { pid: pid as u32, shares }; libc::kill(pid, libc::SIGHUP);
}
let s = SmbState::Running {
pid: pid as u32,
shares,
};
*self.state.lock() = s.clone(); *self.state.lock() = s.clone();
s s
} else { } else {
@@ -204,15 +228,19 @@ impl SmbManager {
} }
fn smbd_present() -> bool { fn smbd_present() -> bool {
let Ok(paths) = std::env::var("PATH") else { return false; }; let Ok(paths) = std::env::var("PATH") else {
return false;
};
for dir in std::env::split_paths(&paths) { for dir in std::env::split_paths(&paths) {
if dir.join("smbd").is_file() { return true; } if dir.join("smbd").is_file() {
return true;
}
} }
false false
} }
const SMB_CONF_GLOBAL: &str = r#"[global] const SMB_CONF_GLOBAL: &str = r"[global]
workgroup = PXEFORGE workgroup = OPENPXE
server min protocol = SMB2 server min protocol = SMB2
smb ports = 445 smb ports = 445
log level = 1 log level = 1
@@ -227,7 +255,16 @@ lock directory = /tmp
state directory = /tmp state directory = /tmp
cache directory = /tmp cache directory = /tmp
pid directory = /tmp pid directory = /tmp
"#; # WinPE reconnect hardening. Windows Setup can reboot mid-install and
# reconnect from the same IP; stale sessions/oplocks otherwise cause
# intermittent `net use` failures on the second stage.
reset on zero vc = yes
oplocks = no
kernel oplocks = no
level2 oplocks = no
strict locking = no
deadtime = 1
";
/// Extract a Windows ISO at `iso_path` into `smb_dir/<slug>/`. Uses /// Extract a Windows ISO at `iso_path` into `smb_dir/<slug>/`. Uses
/// `7z` when available (most reliable for UDF + ISO9660 hybrid images); /// `7z` when available (most reliable for UDF + ISO9660 hybrid images);
@@ -237,10 +274,14 @@ pid directory = /tmp
/// Idempotent: if the target dir already contains `sources/boot.wim`, we /// Idempotent: if the target dir already contains `sources/boot.wim`, we
/// skip extraction. Callers who want a forced re-extract should remove the /// skip extraction. Callers who want a forced re-extract should remove the
/// dir first. /// dir first.
pub fn extract_windows_iso(iso_path: &Path, smb_dir: &Path, slug: &str) -> std::io::Result<PathBuf> { pub fn extract_windows_iso(
iso_path: &Path,
smb_dir: &Path,
slug: &str,
) -> std::io::Result<PathBuf> {
let target = smb_dir.join(slug); let target = smb_dir.join(slug);
if target.join("sources").join("boot.wim").is_file() { if target.join("sources").join("boot.wim").is_file() {
tracing::debug!(target: "pxeforge::smb", slug, "ISO already extracted, skipping"); tracing::debug!(target: "openpxe::smb", slug, "ISO already extracted, skipping");
return Ok(target); return Ok(target);
} }
std::fs::create_dir_all(&target)?; std::fs::create_dir_all(&target)?;
@@ -255,9 +296,11 @@ pub fn extract_windows_iso(iso_path: &Path, smb_dir: &Path, slug: &str) -> std::
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.output()?; .output()?;
if out.status.success() { return Ok(target); } if out.status.success() {
return Ok(target);
}
tracing::warn!( tracing::warn!(
target: "pxeforge::smb", target: "openpxe::smb",
stderr=%String::from_utf8_lossy(&out.stderr), stderr=%String::from_utf8_lossy(&out.stderr),
"7z extract failed, trying bsdtar" "7z extract failed, trying bsdtar"
); );
@@ -270,11 +313,13 @@ pub fn extract_windows_iso(iso_path: &Path, smb_dir: &Path, slug: &str) -> std::
.args(["-C"]) .args(["-C"])
.arg(&target) .arg(&target)
.output()?; .output()?;
if out.status.success() { return Ok(target); } if out.status.success() {
return Err(std::io::Error::new( return Ok(target);
std::io::ErrorKind::Other, }
format!("bsdtar failed: {}", String::from_utf8_lossy(&out.stderr)), return Err(std::io::Error::other(format!(
)); "bsdtar failed: {}",
String::from_utf8_lossy(&out.stderr)
)));
} }
Err(std::io::Error::new( Err(std::io::Error::new(
std::io::ErrorKind::NotFound, std::io::ErrorKind::NotFound,
@@ -286,7 +331,9 @@ fn which(cmd: &str) -> Option<PathBuf> {
let paths = std::env::var_os("PATH")?; let paths = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&paths) { for dir in std::env::split_paths(&paths) {
let p = dir.join(cmd); let p = dir.join(cmd);
if p.is_file() { return Some(p); } if p.is_file() {
return Some(p);
}
} }
None None
} }
@@ -307,12 +354,14 @@ mod tests {
fn start_without_smbd_reports_missing() { fn start_without_smbd_reports_missing() {
// Drop smbd from PATH for this test. // Drop smbd from PATH for this test.
let saved = std::env::var_os("PATH"); let saved = std::env::var_os("PATH");
std::env::set_var("PATH", "/usr/nowhere-pxeforge-test"); std::env::set_var("PATH", "/usr/nowhere-openpxe-test");
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let m = SmbManager::new(dir.path().into()); let m = SmbManager::new(dir.path().into());
let st = m.start(); let st = m.start();
// Restore PATH before asserting so any subsequent failure is legible. // Restore PATH before asserting so any subsequent failure is legible.
if let Some(p) = saved { std::env::set_var("PATH", p); } if let Some(p) = saved {
std::env::set_var("PATH", p);
}
assert_eq!(st, SmbState::SmbdMissing); assert_eq!(st, SmbState::SmbdMissing);
} }
@@ -339,5 +388,27 @@ mod tests {
assert!(conf.contains("guest ok = yes")); assert!(conf.contains("guest ok = yes"));
assert!(conf.contains("read only = yes")); assert!(conf.contains("read only = yes"));
assert!(conf.contains("server min protocol = SMB2")); assert!(conf.contains("server min protocol = SMB2"));
assert!(conf.contains("workgroup = OPENPXE"));
}
#[test]
fn write_conf_includes_winpe_reconnect_tuning() {
let dir = tempdir().unwrap();
let m = SmbManager::new(dir.path().into());
m.write_conf().unwrap();
let conf = std::fs::read_to_string(dir.path().join("smb.conf")).unwrap();
for expected in [
"reset on zero vc = yes",
"oplocks = no",
"kernel oplocks = no",
"level2 oplocks = no",
"strict locking = no",
"deadtime = 1",
] {
assert!(
conf.contains(expected),
"missing Windows reconnect Samba option {expected} in:\n{conf}"
);
}
} }
} }
File diff suppressed because it is too large Load Diff
+399 -64
View File
@@ -3,8 +3,8 @@
use crate::entry::{BootEntry, BootKind, KernelArgs}; use crate::entry::{BootEntry, BootKind, KernelArgs};
use crate::introspect::{introspect, DistroFamily, IntrospectionReport}; use crate::introspect::{introspect, DistroFamily, IntrospectionReport};
use bytes::Bytes; use bytes::Bytes;
use openpxe_core::{Error, Result};
use parking_lot::RwLock; use parking_lot::RwLock;
use pxeforge_core::{Error, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::collections::HashMap; use std::collections::HashMap;
@@ -15,25 +15,57 @@ use tokio::io::AsyncWriteExt;
/// Where the bytes for an ISO actually live. /// Where the bytes for an ISO actually live.
/// ///
/// The default is `Local` — uploaded ISOs sit in `<iso_dir>/<id>.iso`. /// `Local` — uploaded ISO, sits at `<iso_dir>/<id>.iso`.
/// `Nfs` entries point at a file inside a remote share that the /// `Smb` (v0.4.65) — remote SMB share, streamed via Samba's
/// `NfsManager` is keeping mounted. We resolve the on-disk path lazily /// userspace `smbclient` CLI subprocess. No kernel mount, no local
/// in [`IsoStore::iso_path_for`] using the `nfs_root` set at startup. /// cache. Sequential whole-file streaming; HTTP Range requests
#[derive(Debug, Clone, Serialize, Deserialize)] /// 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")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum IsoSource { pub enum IsoSource {
#[default]
Local, Local,
/// v0.4.65: SMB via userspace `smbclient` works in any container.
Smb {
share_id: String,
/// Filename at the share root. We don't support nested paths
/// in v0.4.65; ISOs live at the top of the share.
relative_path: String,
},
/// v0.4.67: NFSv3 via the in-process `nfs3_client` crate.
Nfs { Nfs {
mount_id: String, share_id: String,
/// Path relative to the mount point — typically just the filename. /// Filename at the export root.
relative_path: String, relative_path: String,
}, },
} }
impl Default for IsoSource { /// Where the ISO lands in the PXE menu hierarchy.
fn default() -> Self { ///
Self::Local /// Auto-detected family (Debian, Windows, …) still drives BIOS/UEFI
} /// behaviour and per-entry boot args, but the *menu placement* is
/// operator-controlled — an operator who's uploaded a TinyCore live ISO
/// to use as a recovery shim, or a SystemRescue image, can flip its
/// category to `Tools` so it lands next to memtest/shell instead of
/// under Linux Installers.
///
/// Old `meta.json` files without this field deserialize as `Os`, which
/// matches v0.4.1 behaviour (everything goes under OS Installers).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IsoCategory {
/// "OS Installer" — routed via the auto-detected family into the
/// Linux / Windows installer submenus.
#[default]
Os,
/// "Tool" — surfaced under the Tools menu next to memtest, shell,
/// NIC info, etc. Family detection still decides BIOS/UEFI vs
/// wimboot vs sanboot at boot time.
Tools,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -53,6 +85,29 @@ pub struct IsoMeta {
/// Old `meta.json` files without this field deserialize as `Local`. /// Old `meta.json` files without this field deserialize as `Local`.
#[serde(default)] #[serde(default)]
pub source: IsoSource, pub source: IsoSource,
/// Optional bcrypt hash of an operator-set password. When present,
/// `/boot/<entry>.ipxe` returns a `read --secret` prompt instead of
/// the boot script until the client chains back with the correct
/// `?token=...`. We never store, log, or transmit the plaintext.
/// Skipped on serialize when None to keep meta.json clean for
/// the common no-password case.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password_hash: Option<String>,
/// Where the ISO sits in the PXE menu hierarchy — operator-controlled,
/// not driven by family detection. Defaults to [`IsoCategory::Os`].
#[serde(default)]
pub category: IsoCategory,
}
impl IsoMeta {
/// Convenience predicate the HTTP layer + UI can both use.
#[must_use]
pub fn is_password_protected(&self) -> bool {
self.password_hash
.as_deref()
.map(str::trim)
.is_some_and(|h| !h.is_empty())
}
} }
pub struct UploadHandle { pub struct UploadHandle {
@@ -99,6 +154,8 @@ impl UploadHandle {
introspection, introspection,
boot_entries, boot_entries,
source: IsoSource::Local, source: IsoSource::Local,
password_hash: None,
category: IsoCategory::default(),
}; };
store.persist_meta(&meta).await?; store.persist_meta(&meta).await?;
store.insert(meta.clone()); store.insert(meta.clone());
@@ -120,10 +177,6 @@ struct Inner {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct IsoStore { pub struct IsoStore {
iso_dir: Arc<PathBuf>, iso_dir: Arc<PathBuf>,
/// Where NFS mounts land on disk. Set at startup via
/// [`IsoStore::set_nfs_root`]; required for resolving any
/// `IsoSource::Nfs` entry.
nfs_root: Arc<RwLock<Option<PathBuf>>>,
inner: Arc<RwLock<Inner>>, inner: Arc<RwLock<Inner>>,
} }
@@ -131,17 +184,10 @@ impl IsoStore {
pub fn new(iso_dir: PathBuf) -> Self { pub fn new(iso_dir: PathBuf) -> Self {
Self { Self {
iso_dir: Arc::new(iso_dir), iso_dir: Arc::new(iso_dir),
nfs_root: Arc::new(RwLock::new(None)),
inner: Arc::new(RwLock::new(Inner::default())), inner: Arc::new(RwLock::new(Inner::default())),
} }
} }
/// Tell the store where NFS mounts live. Without this set,
/// `IsoSource::Nfs` entries cannot be resolved to a file path.
pub fn set_nfs_root(&self, root: PathBuf) {
*self.nfs_root.write() = Some(root);
}
pub async fn ensure_dirs(&self) -> Result<()> { pub async fn ensure_dirs(&self) -> Result<()> {
tokio::fs::create_dir_all(self.iso_dir.as_path()).await?; tokio::fs::create_dir_all(self.iso_dir.as_path()).await?;
Ok(()) Ok(())
@@ -155,8 +201,14 @@ impl IsoStore {
let mut entries = tokio::fs::read_dir(self.iso_dir.as_path()).await?; let mut entries = tokio::fs::read_dir(self.iso_dir.as_path()).await?;
while let Some(e) = entries.next_entry().await? { while let Some(e) = entries.next_entry().await? {
let p = e.path(); let p = e.path();
if p.extension().and_then(|s| s.to_str()) != Some("json") { continue; } if p.extension().and_then(|s| s.to_str()) != Some("json") {
if !p.file_name().and_then(|s| s.to_str()).map_or(false, |n| n.ends_with(".meta.json")) { continue;
}
if !p
.file_name()
.and_then(|s| s.to_str())
.is_some_and(|n| n.ends_with(".meta.json"))
{
continue; continue;
} }
if let Ok(text) = tokio::fs::read_to_string(&p).await { if let Ok(text) = tokio::fs::read_to_string(&p).await {
@@ -195,6 +247,9 @@ impl IsoStore {
return Err(Error::Invalid(format!("iso '{id}' already exists"))); return Err(Error::Invalid(format!("iso '{id}' already exists")));
} }
let partial_path = self.iso_dir.join(format!("{id}.partial")); let partial_path = self.iso_dir.join(format!("{id}.partial"));
if partial_path.exists() {
return Err(Error::Invalid(format!("iso '{id}' is already uploading")));
}
let file = tokio::fs::File::create(&partial_path).await?; let file = tokio::fs::File::create(&partial_path).await?;
Ok(UploadHandle { Ok(UploadHandle {
id, id,
@@ -218,7 +273,8 @@ impl IsoStore {
pub fn list(&self) -> Vec<IsoMeta> { pub fn list(&self) -> Vec<IsoMeta> {
let g = self.inner.read(); let g = self.inner.read();
let mut v: Vec<_> = g.isos.values().cloned().collect(); let mut v: Vec<_> = g.isos.values().cloned().collect();
v.sort_by(|a, b| b.uploaded_at.cmp(&a.uploaded_at)); // Newest-first by upload time.
v.sort_by_key(|m| std::cmp::Reverse(m.uploaded_at));
v v
} }
@@ -227,36 +283,39 @@ impl IsoStore {
self.inner.read().isos.get(id).cloned() self.inner.read().isos.get(id).cloned()
} }
/// Resolve an ISO id to its on-disk path, if any. For local entries /// Resolve an ISO id to its on-disk path, if any. For local
/// this is `<iso_dir>/<id>.iso`; for NFS entries it's /// (uploaded) ISOs this is `<iso_dir>/<id>.iso`. For SMB-sourced
/// `<nfs_root>/<mount_id>/<relative_path>`. Returns None if the file /// ISOs there is no on-disk path — the HTTP handler must stream
/// is missing or the source isn't resolvable (e.g. NFS share /// via `SmbShareManager::stream_iso` instead. Returns `None` for
/// unmounted). /// SMB sources or when the file is missing.
pub fn iso_path_for(&self, id: &str) -> Option<PathBuf> { pub fn iso_path_for(&self, id: &str) -> Option<PathBuf> {
let meta = self.get(id)?; let meta = self.get(id)?;
let path = match &meta.source { match &meta.source {
IsoSource::Local => self.iso_path(id), IsoSource::Local => {
IsoSource::Nfs { let path = self.iso_path(id);
mount_id,
relative_path,
} => {
let root = self.nfs_root.read().clone()?;
root.join(mount_id).join(relative_path)
}
};
if path.exists() { if path.exists() {
Some(path) Some(path)
} else { } else {
None 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; /// Delete an ISO and its sidecar metadata. Only acts on local
/// for NFS-backed ISOs the operator must remove the file from the /// (uploaded) ISOs; for SMB-backed ISOs the operator must remove
/// share or unmount the NFS share entirely. /// the file from the share or unregister the share entirely.
pub async fn delete(&self, id: &str) -> Result<()> { pub async fn delete(&self, id: &str) -> Result<()> {
let meta = self.get(id); let meta = self.get(id);
let is_local = matches!(meta.as_ref().map(|m| &m.source), Some(IsoSource::Local) | None); let is_local = matches!(
meta.as_ref().map(|m| &m.source),
Some(IsoSource::Local) | None
);
if is_local { if is_local {
let iso = self.iso_path(id); let iso = self.iso_path(id);
let meta_path = self.meta_path(id); let meta_path = self.meta_path(id);
@@ -267,10 +326,10 @@ impl IsoStore {
Ok(()) Ok(())
} }
/// Register an externally-sourced ISO (e.g. NFS-mounted). Used by /// Register an externally-sourced ISO (SMB share, etc.). Used by
/// `NfsManager` after walking a freshly-mounted share. We do **not** /// `SmbShareManager` after listing a share. We do **not** persist
/// persist a `meta.json` on disk for these — the source of truth is /// a `meta.json` on disk for these — the source of truth is the
/// the share itself, and the NFS manager re-scans on startup. /// share itself, and the manager re-scans on startup.
pub fn register_external( pub fn register_external(
&self, &self,
id: String, id: String,
@@ -289,19 +348,155 @@ impl IsoStore {
introspection, introspection,
boot_entries, boot_entries,
source, source,
password_hash: None,
category: IsoCategory::default(),
}; };
self.inner.write().isos.insert(id, meta); self.inner.write().isos.insert(id, meta);
} }
/// Drop every entry that belongs to `mount_id`. Used by the NFS /// Drop every entry that belongs to `share_id`. Used by the SMB
/// manager when an operator removes a share, or before re-scanning /// and NFS share managers when an operator removes a share, or
/// to clean out stale entries. /// before re-scanning to clean out stale entries. The same id
pub fn drop_external_source(&self, mount_id: &str) { /// space serves both protocols — share ids are slugified from
/// `server+share` (SMB) or `server+export` (NFS) and the
/// protocol-specific prefix prevents collisions.
pub fn drop_external_source(&self, share_id: &str) {
let mut g = self.inner.write(); let mut g = self.inner.write();
g.isos.retain(|_, m| { g.isos.retain(|_, m| match &m.source {
!matches!(&m.source, IsoSource::Nfs { mount_id: mid, .. } if mid == mount_id) IsoSource::Smb { share_id: sid, .. } | IsoSource::Nfs { share_id: sid, .. } => {
sid != share_id
}
IsoSource::Local => true,
}); });
} }
/// Set or clear an ISO's boot password.
///
/// `Some("plaintext")` hashes via bcrypt (cost 10 — fast enough for
/// an interactive iPXE prompt, slow enough to be hostile to brute
/// force on a leaked meta.json) and persists.
///
/// `None` removes the password — the next /boot/<id>.ipxe request
/// returns the script directly without a prompt.
///
/// We never store, log, or transmit the plaintext.
pub async fn set_password(&self, id: &str, password: Option<&str>) -> Result<()> {
let new_hash = match password {
None => None,
Some(pw) => {
let pw = pw.trim();
if pw.is_empty() {
None
} else {
let h = bcrypt::hash(pw, bcrypt::DEFAULT_COST)
.map_err(|e| Error::Other(e.into()))?;
Some(h)
}
}
};
// Update in-memory + grab a clone for persistence outside the lock.
let updated = {
let mut g = self.inner.write();
let m = g
.isos
.get_mut(id)
.ok_or_else(|| Error::Invalid(format!("no such iso '{id}'")))?;
m.password_hash = new_hash;
m.clone()
};
// NFS-sourced ISOs have no on-disk meta.json — skip persistence
// for them (the password lives in memory until the manager
// re-scans the share, then it's gone). Document this in the API
// handler so the operator knows.
if matches!(updated.source, IsoSource::Local) {
self.persist_meta(&updated).await?;
}
Ok(())
}
/// Flip an ISO's menu category. Persists to `meta.json` for local
/// ISOs; NFS-sourced ISOs keep the change in memory only (the next
/// re-scan would overwrite it anyway).
pub async fn set_category(&self, id: &str, category: IsoCategory) -> Result<IsoMeta> {
let updated = {
let mut g = self.inner.write();
let m = g
.isos
.get_mut(id)
.ok_or_else(|| Error::Invalid(format!("no such iso '{id}'")))?;
m.category = category;
m.clone()
};
if matches!(updated.source, IsoSource::Local) {
self.persist_meta(&updated).await?;
}
Ok(updated)
}
/// Absolute path to the directory holding local ISO uploads. Used
/// by the HTTP layer for the disk-space endpoint — the volume that
/// hosts this directory is what runs out of room first.
#[must_use]
pub fn iso_dir(&self) -> PathBuf {
self.iso_dir.as_path().to_path_buf()
}
/// `(total_bytes, available_bytes)` for the filesystem hosting the
/// ISO directory. Returns `None` if `statvfs` fails (read-only
/// filesystem with no quota, mount disappeared, …) — callers
/// should treat that as "unknown" rather than zero.
///
/// Lives here rather than the HTTP crate because `http-api`'s
/// `#![forbid(unsafe_code)]` rules out the libc FFI directly, and
/// because this is naturally an `IsoStore` question — the volume
/// of interest is whatever's hosting the iso dir.
#[must_use]
pub fn disk_usage(&self) -> Option<(u64, u64)> {
disk_usage_for(self.iso_dir.as_path())
}
/// Verify a candidate password against the stored bcrypt hash.
/// Returns:
/// - `Ok(true)` — match (or the ISO has no password set; boot is open)
/// - `Ok(false)` — mismatch
/// - `Err(_)` — id not found, or bcrypt error
pub fn verify_password(&self, id: &str, candidate: &str) -> Result<bool> {
let meta = self
.get(id)
.ok_or_else(|| Error::Invalid(format!("no such iso '{id}'")))?;
let Some(hash) = meta.password_hash else {
return Ok(true); // no password set — anyone can boot
};
bcrypt::verify(candidate, &hash).map_err(|e| Error::Other(e.into()))
}
}
/// Resolve `(total, available)` bytes for the filesystem hosting `path`.
/// Returns `None` if `statvfs` fails.
#[allow(unsafe_code)]
fn disk_usage_for(path: &std::path::Path) -> Option<(u64, u64)> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let c = CString::new(path.as_os_str().as_bytes()).ok()?;
// SAFETY: `statvfs` is repr(C); a zeroed value is a valid initial
// state per POSIX. The FFI call writes every field we then read.
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
// SAFETY: `c` is a NUL-terminated C string pointing into a stack
// CString that outlives this call; `&mut stat` is a unique aligned
// pointer to a stack-local `statvfs`. The kernel writes through
// it but does not retain the pointer past return.
let rc = unsafe { libc::statvfs(c.as_ptr(), &raw mut stat) };
if rc != 0 {
return None;
}
// Use f_frsize (fundamental block size). f_bsize is "preferred I/O
// block" and doesn't always match the unit f_blocks is denominated
// in — on some BSDs it would over-report by a factor of 8.
let frsize = stat.f_frsize as u64;
let total = stat.f_blocks as u64 * frsize;
let avail = stat.f_bavail as u64 * frsize;
Some((total, avail))
} }
fn slugify(filename: &str) -> String { fn slugify(filename: &str) -> String {
@@ -346,7 +541,10 @@ pub fn generate_boot_entries_for(
/// Build `BootEntry`s from the introspection report. URLs are relative — /// Build `BootEntry`s from the introspection report. URLs are relative —
/// the HTTP layer rewrites them with the public base URL per request. /// the HTTP layer rewrites them with the public base URL per request.
fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> Vec<BootEntry> { fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> Vec<BootEntry> {
let title = r.volume_label.clone().unwrap_or_else(|| filename.to_string()); let title = r
.volume_label
.clone()
.unwrap_or_else(|| filename.to_string());
match r.family { match r.family {
DistroFamily::WindowsPe if r.has_boot_wim => { DistroFamily::WindowsPe if r.has_boot_wim => {
// Standard wimboot chain. Paths are in-ISO; the HTTP layer maps // Standard wimboot chain. Paths are in-ISO; the HTTP layer maps
@@ -370,12 +568,22 @@ fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> V
fam if r.kernel_path.is_some() => { fam if r.kernel_path.is_some() => {
let base = format!("iso/{id}"); let base = format!("iso/{id}");
let kernel_url = format!("{base}{}", r.kernel_path.as_deref().unwrap_or("")); let kernel_url = format!("{base}{}", r.kernel_path.as_deref().unwrap_or(""));
let initrd_urls = r.initrd_paths.iter().map(|p| format!("{base}{p}")).collect(); let initrd_urls = r
let args = KernelArgs { cmdline: linux_cmdline(fam, id) }; .initrd_paths
.iter()
.map(|p| format!("{base}{p}"))
.collect();
let args = KernelArgs {
cmdline: linux_cmdline(fam, id),
};
vec![BootEntry { vec![BootEntry {
id: format!("{id}-linux"), id: format!("{id}-linux"),
title, title,
kind: BootKind::LinuxKernel { kernel_url, initrd_urls, args }, kind: BootKind::LinuxKernel {
kernel_url,
initrd_urls,
args,
},
}] }]
} }
_ => { _ => {
@@ -384,7 +592,9 @@ fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> V
vec![BootEntry { vec![BootEntry {
id: format!("{id}-sanboot"), id: format!("{id}-sanboot"),
title: format!("{title} (SAN boot — may fail for >1GiB ISOs)"), title: format!("{title} (SAN boot — may fail for >1GiB ISOs)"),
kind: BootKind::SanBootIso { iso_url: format!("iso/{id}.iso") }, kind: BootKind::SanBootIso {
iso_url: format!("iso/{id}.iso"),
},
}] }]
} }
} }
@@ -394,8 +604,17 @@ fn linux_cmdline(family: DistroFamily, id: &str) -> String {
// The HTTP layer resolves `${base-url}` at render time. // The HTTP layer resolves `${base-url}` at render time.
let iso_url = format!("${{base-url}}/iso/{id}.iso"); let iso_url = format!("${{base-url}}/iso/{id}.iso");
match family { match family {
// VMware-UEFI fix (v0.4.5, matching Bootimus v0.1.67's Casper
// patch): drop `netboot=url url=… ---` in favour of the
// canonical Casper option `iso-url=` and add `ds=nocloud` so
// cloud-init / subiquity (live-server) doesn't stall waiting on
// a metadata datasource that doesn't exist in PXE. Without
// `ds=nocloud`, Ubuntu live-server / Mint / Pop!_OS / elementary
// ISOs would boot fine on bare-metal UEFI but hang at "cloud-init
// running" on VMware-UEFI guests because the vmxnet3 driver's
// late-init upsets cloud-init's network probe.
DistroFamily::DebianUbuntu => format!( DistroFamily::DebianUbuntu => format!(
"boot=casper netboot=url url={iso_url} ip=dhcp ---" "boot=casper initrd=initrd ds=nocloud ip=dhcp iso-url={iso_url}"
), ),
DistroFamily::RhelFedora => format!( DistroFamily::RhelFedora => format!(
"inst.repo={iso_url} inst.stage2={iso_url} ip=dhcp" "inst.repo={iso_url} inst.stage2={iso_url} ip=dhcp"
@@ -403,9 +622,9 @@ fn linux_cmdline(family: DistroFamily, id: &str) -> String {
DistroFamily::OpenSuse => format!( DistroFamily::OpenSuse => format!(
"install={iso_url} netsetup=dhcp" "install={iso_url} netsetup=dhcp"
), ),
DistroFamily::Arch => format!( DistroFamily::Arch => {
"archiso_http_srv=${{base-url}}/iso/ archisobasedir=arch ip=dhcp copytoram" "archiso_http_srv=${base-url}/iso/ archisobasedir=arch ip=dhcp copytoram".to_string()
), }
DistroFamily::Alpine => format!( DistroFamily::Alpine => format!(
"alpine_repo=${{base-url}}/iso/{id}/ modloop=${{base-url}}/iso/{id}/boot/modloop-lts ip=dhcp" "alpine_repo=${{base-url}}/iso/{id}/ modloop=${{base-url}}/iso/{id}/boot/modloop-lts ip=dhcp"
), ),
@@ -416,6 +635,8 @@ fn linux_cmdline(family: DistroFamily, id: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::introspect::{DistroFamily, IntrospectionReport};
use tempfile::tempdir;
#[test] #[test]
fn slugify_basic() { fn slugify_basic() {
@@ -427,4 +648,118 @@ mod tests {
// If a path sneaks in, file_stem strips the directory — OK, not a hazard. // If a path sneaks in, file_stem strips the directory — OK, not a hazard.
assert_eq!(slugify("/etc/passwd"), "passwd"); assert_eq!(slugify("/etc/passwd"), "passwd");
} }
#[test]
fn casper_cmdline_vmware_uefi_safe() {
// v0.4.5 regression guard: the Debian/Ubuntu cmdline must use
// the canonical Casper `iso-url=` option and include
// `ds=nocloud` so VMware-UEFI guests don't hang at "cloud-init
// running" waiting on a metadata datasource that PXE can't
// provide. The legacy `netboot=url url=… ---` form is gone for
// good.
let s = linux_cmdline(DistroFamily::DebianUbuntu, "ubuntu-24-04");
assert!(s.contains("boot=casper"), "{s}");
assert!(s.contains("iso-url=${base-url}/iso/ubuntu-24-04.iso"), "{s}");
assert!(s.contains("ds=nocloud"), "{s}");
assert!(s.contains("ip=dhcp"), "{s}");
assert!(!s.contains("netboot=url"), "legacy option leaked: {s}");
assert!(!s.contains(" --- "), "stray ---: {s}");
}
fn fake_meta(id: &str) -> IsoMeta {
IsoMeta {
id: id.into(),
filename: format!("{id}.iso"),
size_bytes: 0,
sha256_hex: None,
uploaded_at: OffsetDateTime::now_utc(),
introspection: IntrospectionReport {
family: DistroFamily::Unknown,
volume_label: None,
kernel_path: None,
initrd_paths: vec![],
has_boot_wim: false,
},
boot_entries: vec![],
source: IsoSource::Local,
password_hash: None,
category: IsoCategory::default(),
}
}
#[tokio::test]
async fn password_round_trip_set_verify_clear() {
let dir = tempdir().unwrap();
let store = IsoStore::new(dir.path().to_path_buf());
store.ensure_dirs().await.unwrap();
store
.inner
.write()
.isos
.insert("alpha".into(), fake_meta("alpha"));
// No password set — verify_password returns Ok(true) for any input.
assert!(store.verify_password("alpha", "anything").unwrap());
assert!(!store.get("alpha").unwrap().is_password_protected());
// Set a password.
store.set_password("alpha", Some("hunter2")).await.unwrap();
let m = store.get("alpha").unwrap();
assert!(m.is_password_protected());
assert!(m.password_hash.unwrap().starts_with("$2"));
// Verify correct + wrong.
assert!(store.verify_password("alpha", "hunter2").unwrap());
assert!(!store.verify_password("alpha", "wrong").unwrap());
assert!(!store.verify_password("alpha", "").unwrap());
// Clear by passing None or an empty string.
store.set_password("alpha", None).await.unwrap();
assert!(!store.get("alpha").unwrap().is_password_protected());
store.set_password("alpha", Some("again")).await.unwrap();
store.set_password("alpha", Some(" ")).await.unwrap();
assert!(!store.get("alpha").unwrap().is_password_protected());
}
#[tokio::test]
async fn set_password_for_unknown_id_errors() {
let dir = tempdir().unwrap();
let store = IsoStore::new(dir.path().to_path_buf());
store.ensure_dirs().await.unwrap();
let r = store.set_password("does-not-exist", Some("pw")).await;
assert!(matches!(r, Err(Error::Invalid(_))));
}
#[tokio::test]
async fn begin_upload_rejects_existing_partial_file() {
let dir = tempdir().unwrap();
let store = IsoStore::new(dir.path().to_path_buf());
store.ensure_dirs().await.unwrap();
tokio::fs::write(dir.path().join("ubuntu.partial"), b"in-flight")
.await
.unwrap();
let r = store.begin_upload("ubuntu.iso").await;
assert!(matches!(r, Err(Error::Invalid(_))));
}
#[tokio::test]
async fn password_persists_via_meta_json_for_local_isos() {
// Hash makes it onto disk so it survives a restart.
let dir = tempdir().unwrap();
let store = IsoStore::new(dir.path().to_path_buf());
store.ensure_dirs().await.unwrap();
let meta = fake_meta("alpha");
store.persist_meta(&meta).await.unwrap();
store.insert(meta);
store.set_password("alpha", Some("s3cret")).await.unwrap();
// Re-load from disk and confirm the hash came back.
let store2 = IsoStore::new(dir.path().to_path_buf());
store2.load_from_disk().await.unwrap();
let reloaded = store2.get("alpha").expect("reloaded");
assert!(reloaded.is_password_protected());
assert!(store2.verify_password("alpha", "s3cret").unwrap());
assert!(!store2.verify_password("alpha", "wrong").unwrap());
}
} }
+412
View File
@@ -0,0 +1,412 @@
//! Unattended-install answer-file store (v0.5.2).
//!
//! Operators upload the answer file their installer expects — a RHEL/
//! Fedora **Kickstart**, a Debian **Preseed**, an Ubuntu **Autoinstall**
//! cloud-init user-data, or a Windows **answer file** (`autounattend.xml`)
//! — and OpenPXE serves it on demand to the booting machine. Files live
//! in their own directory (`<unattended_dir>/`), deliberately *not* under
//! `iso_dir`, so they never appear in the ISO listing or the PXE menu.
//!
//! Storage mirrors [`crate::store::IsoStore`]: in-memory map authoritative
//! for the process, sidecar `*.meta.json` on disk is the source of truth on
//! restart. The raw answer file sits beside it as `<id>.file`.
//!
//! Templating is applied at *serve* time, not store time — see
//! [`render_template`]. The stored bytes are exactly what the operator
//! uploaded; per-host hostname/IP/MAC values are substituted into a copy
//! when the file is fetched for a specific client.
use crate::store::slugify_str;
use openpxe_core::{Error, Result};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use time::OffsetDateTime;
/// Disk + memory cap for one answer file. Kickstarts/preseeds/cloud-init
/// configs are a few KB; 1 MiB is a comfortable ceiling that still bounds
/// abuse.
pub const MAX_UNATTENDED_BYTES: usize = 1024 * 1024;
/// Which installer the answer file targets. Drives the kernel-argument
/// injection in the boot chain.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UnattendedKind {
/// RHEL / Fedora / CentOS / AlmaLinux / Rocky — `inst.ks=<url>`.
Kickstart,
/// Debian / older Ubuntu — `auto=true priority=critical url=<url>`.
Preseed,
/// Ubuntu 20.04+ Subiquity autoinstall — cloud-init NoCloud:
/// `autoinstall ds=nocloud-net;s=<url>/`.
Autoinstall,
/// Windows Setup answer file (`autounattend.xml`). Served, not
/// auto-injected (Windows reads it from media/USB, not a kernel arg).
AnswerFile,
/// Couldn't classify — stored + served, no auto-injection.
#[default]
Unknown,
}
impl UnattendedKind {
#[must_use]
pub fn label(self) -> &'static str {
match self {
UnattendedKind::Kickstart => "Kickstart",
UnattendedKind::Preseed => "Preseed",
UnattendedKind::Autoinstall => "Autoinstall",
UnattendedKind::AnswerFile => "Answer file",
UnattendedKind::Unknown => "Unknown",
}
}
}
/// Lowercase file extension (no dot), or `None` if there isn't one.
fn ext_lower(filename: &str) -> Option<String> {
std::path::Path::new(filename)
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
}
/// Classify an upload from its filename + a peek at its content. Best
/// effort: extension first, then a content sniff to disambiguate the
/// `.cfg` case (both Kickstart and Preseed use it).
#[must_use]
pub fn classify(filename: &str, content: &[u8]) -> UnattendedKind {
let lower_name = filename.to_ascii_lowercase();
let ext = ext_lower(filename);
let text = String::from_utf8_lossy(&content[..content.len().min(8192)]);
let looks_preseed = text.contains("d-i ") || text.contains("preseed/");
let looks_kickstart = text.contains("%packages")
|| text.contains("\nlang ")
|| text.contains("\nkeyboard ")
|| text.contains("bootloader --")
|| text.starts_with("install");
let looks_cloud_init = text.contains("autoinstall")
|| text.contains("#cloud-config")
|| text.contains("version: 1");
match ext.as_deref() {
Some("ks") => return UnattendedKind::Kickstart,
Some("seed") => return UnattendedKind::Preseed,
Some("xml") => return UnattendedKind::AnswerFile,
Some("yaml" | "yml") => return UnattendedKind::Autoinstall,
Some("cfg") => {
return if looks_kickstart && !looks_preseed {
UnattendedKind::Kickstart
} else {
UnattendedKind::Preseed
};
}
_ => {}
}
if lower_name == "user-data" {
return UnattendedKind::Autoinstall;
}
// No recognised extension — fall back to content sniffing.
if looks_cloud_init {
UnattendedKind::Autoinstall
} else if looks_kickstart {
UnattendedKind::Kickstart
} else if looks_preseed {
UnattendedKind::Preseed
} else {
UnattendedKind::Unknown
}
}
/// True if the filename carries an extension we accept for upload. We
/// also accept the bare `user-data` name (cloud-init NoCloud convention).
#[must_use]
pub fn is_accepted_filename(filename: &str) -> bool {
if filename.trim().eq_ignore_ascii_case("user-data") {
return true;
}
matches!(
ext_lower(filename).as_deref(),
Some("ks" | "cfg" | "seed" | "yaml" | "yml" | "xml")
)
}
/// Sidecar metadata for a stored answer file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnattendedMeta {
/// URL-safe slug, unique within the store.
pub id: String,
/// Original upload filename, shown in the UI.
pub filename: String,
pub kind: UnattendedKind,
pub size_bytes: u64,
#[serde(with = "time::serde::rfc3339")]
pub uploaded_at: OffsetDateTime,
}
#[derive(Debug, Default)]
struct Inner {
files: HashMap<String, UnattendedMeta>,
}
/// In-memory + on-disk answer-file registry. Cheap to clone.
#[derive(Debug, Clone)]
pub struct UnattendedStore {
dir: Arc<PathBuf>,
inner: Arc<RwLock<Inner>>,
}
impl UnattendedStore {
#[must_use]
pub fn new(dir: PathBuf) -> Self {
Self {
dir: Arc::new(dir),
inner: Arc::new(RwLock::new(Inner::default())),
}
}
pub async fn ensure_dir(&self) -> Result<()> {
tokio::fs::create_dir_all(self.dir.as_path()).await?;
Ok(())
}
/// Scan the directory on startup, loading every `*.meta.json` sidecar.
pub async fn load_from_disk(&self) -> Result<()> {
self.ensure_dir().await?;
let mut entries = tokio::fs::read_dir(self.dir.as_path()).await?;
while let Some(e) = entries.next_entry().await? {
let p = e.path();
let is_meta = p
.file_name()
.and_then(|s| s.to_str())
.is_some_and(|n| n.ends_with(".meta.json"));
if !is_meta {
continue;
}
if let Ok(text) = tokio::fs::read_to_string(&p).await {
if let Ok(meta) = serde_json::from_str::<UnattendedMeta>(&text) {
self.inner.write().files.insert(meta.id.clone(), meta);
}
}
}
Ok(())
}
fn data_path(&self, id: &str) -> PathBuf {
self.dir.join(format!("{id}.file"))
}
fn meta_path(&self, id: &str) -> PathBuf {
self.dir.join(format!("{id}.meta.json"))
}
/// Mint a unique slug from the upload filename's stem.
fn unique_id(&self, filename: &str) -> String {
let stem = filename.rsplit_once('.').map_or(filename, |(s, _)| s);
let base = {
let s = slugify_str(stem);
if s.is_empty() {
"unattended".to_string()
} else {
s
}
};
let g = self.inner.read();
if !g.files.contains_key(&base) {
return base;
}
for n in 1.. {
let candidate = format!("{base}-{n}");
if !g.files.contains_key(&candidate) {
return candidate;
}
}
unreachable!("u64 ids exhausted")
}
/// Store an uploaded answer file. Validates type + size, classifies,
/// writes the bytes + a sidecar, and returns the new metadata.
pub async fn add(&self, filename: &str, bytes: &[u8]) -> Result<UnattendedMeta> {
if !is_accepted_filename(filename) {
return Err(Error::Invalid(format!(
"unsupported answer-file type '{filename}'. Accepted: .ks, .cfg, .seed, .yaml, .yml, .xml, user-data"
)));
}
if bytes.len() > MAX_UNATTENDED_BYTES {
return Err(Error::Invalid(format!(
"answer file too large ({} bytes, max {MAX_UNATTENDED_BYTES})",
bytes.len()
)));
}
self.ensure_dir().await?;
let kind = classify(filename, bytes);
let id = self.unique_id(filename);
let meta = UnattendedMeta {
id: id.clone(),
filename: filename.to_string(),
kind,
size_bytes: bytes.len() as u64,
uploaded_at: OffsetDateTime::now_utc(),
};
// Atomic data write: tmp -> rename.
let data = self.data_path(&id);
let tmp = data.with_extension("file.tmp");
tokio::fs::write(&tmp, bytes).await?;
tokio::fs::rename(&tmp, &data).await?;
let meta_text = serde_json::to_string_pretty(&meta).map_err(|e| Error::Other(e.into()))?;
tokio::fs::write(self.meta_path(&id), meta_text).await?;
self.inner.write().files.insert(id.clone(), meta.clone());
tracing::info!(
target: "openpxe::unattended",
id = %id, file = %filename, kind = ?kind, size = bytes.len(),
"unattended answer file stored"
);
Ok(meta)
}
#[must_use]
pub fn list(&self) -> Vec<UnattendedMeta> {
let g = self.inner.read();
let mut v: Vec<_> = g.files.values().cloned().collect();
v.sort_by_key(|m| std::cmp::Reverse(m.uploaded_at));
v
}
#[must_use]
pub fn get(&self, id: &str) -> Option<UnattendedMeta> {
self.inner.read().files.get(id).cloned()
}
/// Read the raw stored bytes for `id`.
pub async fn read(&self, id: &str) -> Result<Vec<u8>> {
if !self.inner.read().files.contains_key(id) {
return Err(Error::NotFound(format!("no unattended file '{id}'")));
}
let bytes = tokio::fs::read(self.data_path(id)).await?;
Ok(bytes)
}
/// Remove a file + its sidecar. Returns true if something was removed.
pub async fn remove(&self, id: &str) -> bool {
let existed = self.inner.write().files.remove(id).is_some();
if existed {
let _ = tokio::fs::remove_file(self.data_path(id)).await;
let _ = tokio::fs::remove_file(self.meta_path(id)).await;
}
existed
}
#[must_use]
pub fn len(&self) -> usize {
self.inner.read().files.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
/// Substitute the per-host template tokens into an answer file at serve
/// time. Recognised tokens (case-sensitive, double-brace): `{{HOSTNAME}}`,
/// `{{IP}}`, `{{MAC}}`. Unset values render as an empty string so a
/// half-filled profile never leaves a literal `{{IP}}` in the file.
#[must_use]
pub fn render_template(
content: &str,
mac: Option<&str>,
hostname: Option<&str>,
ip: Option<&str>,
) -> String {
content
.replace("{{HOSTNAME}}", hostname.unwrap_or(""))
.replace("{{IP}}", ip.unwrap_or(""))
.replace("{{MAC}}", mac.unwrap_or(""))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn classify_by_extension() {
assert_eq!(
classify(" subiquity.yaml", b""),
UnattendedKind::Autoinstall
);
assert_eq!(classify("ks.ks", b""), UnattendedKind::Kickstart);
assert_eq!(classify("preseed.seed", b""), UnattendedKind::Preseed);
assert_eq!(
classify("autounattend.xml", b"<xml/>"),
UnattendedKind::AnswerFile
);
assert_eq!(classify("user-data", b""), UnattendedKind::Autoinstall);
}
#[test]
fn classify_cfg_by_content() {
assert_eq!(
classify("answer.cfg", b"d-i debian-installer/locale string en_US"),
UnattendedKind::Preseed
);
assert_eq!(
classify("answer.cfg", b"install\n%packages\n@core\n%end\n"),
UnattendedKind::Kickstart
);
}
#[test]
fn accepted_filenames() {
assert!(is_accepted_filename("a.ks"));
assert!(is_accepted_filename("USER-DATA".to_lowercase().as_str()));
assert!(is_accepted_filename("autounattend.XML"));
assert!(!is_accepted_filename("evil.sh"));
assert!(!is_accepted_filename("image.iso"));
}
#[test]
fn template_substitutes_and_blanks_unset() {
let body = "ip={{IP}} host={{HOSTNAME}} mac={{MAC}}";
let out = render_template(body, Some("aa:bb"), Some("node1"), None);
assert_eq!(out, "ip= host=node1 mac=aa:bb");
}
#[tokio::test]
async fn add_list_read_remove_round_trip() {
let dir = tempdir().unwrap();
let s = UnattendedStore::new(dir.path().join("unattended"));
let meta = s
.add("rocky.ks", b"install\n%packages\n@core\n%end\n")
.await
.unwrap();
assert_eq!(meta.kind, UnattendedKind::Kickstart);
assert_eq!(s.len(), 1);
let got = s.read(&meta.id).await.unwrap();
assert!(got.starts_with(b"install"));
// Survives a reload.
let s2 = UnattendedStore::new(dir.path().join("unattended"));
s2.load_from_disk().await.unwrap();
assert!(s2.get(&meta.id).is_some());
assert!(s2.remove(&meta.id).await);
assert!(s2.get(&meta.id).is_none());
}
#[tokio::test]
async fn rejects_bad_type_and_oversize() {
let dir = tempdir().unwrap();
let s = UnattendedStore::new(dir.path().join("unattended"));
assert!(s.add("evil.sh", b"#!/bin/sh").await.is_err());
let big = vec![b'x'; MAX_UNATTENDED_BYTES + 1];
assert!(s.add("big.ks", &big).await.is_err());
}
#[tokio::test]
async fn ids_are_unique() {
let dir = tempdir().unwrap();
let s = UnattendedStore::new(dir.path().join("unattended"));
let a = s.add("ks.ks", b"install").await.unwrap();
let b = s.add("ks.ks", b"install").await.unwrap();
assert_ne!(a.id, b.id);
}
}
+72 -18
View File
@@ -54,7 +54,10 @@ pub struct WimPatcher {
impl WimPatcher { impl WimPatcher {
#[must_use] #[must_use]
pub fn new(smb_host: String, smb_share: String) -> Self { pub fn new(smb_host: String, smb_share: String) -> Self {
Self { smb_host, smb_share } Self {
smb_host,
smb_share,
}
} }
/// Apply WinPE patches to `boot.wim` inside `extracted_iso_dir`. Returns /// Apply WinPE patches to `boot.wim` inside `extracted_iso_dir`. Returns
@@ -72,31 +75,40 @@ impl WimPatcher {
let work = match tempfile::tempdir() { let work = match tempfile::tempdir() {
Ok(d) => d, Ok(d) => d,
Err(e) => return WinPatchState::Failed { reason: format!("tempdir: {e}") }, Err(e) => {
return WinPatchState::Failed {
reason: format!("tempdir: {e}"),
}
}
}; };
// Stage the two files we want present at /Windows/System32/. // Stage the two files we want present at /Windows/System32/.
let staging = work.path().join("stage/Windows/System32"); let staging = work.path().join("stage/Windows/System32");
if let Err(e) = std::fs::create_dir_all(&staging) { if let Err(e) = std::fs::create_dir_all(&staging) {
return WinPatchState::Failed { reason: format!("staging mkdir: {e}") }; return WinPatchState::Failed {
reason: format!("staging mkdir: {e}"),
};
} }
if let Err(e) = std::fs::write(staging.join("winpeshl.ini"), WINPESHL_INI) { if let Err(e) = std::fs::write(staging.join("winpeshl.ini"), WINPESHL_INI) {
return WinPatchState::Failed { reason: format!("write winpeshl.ini: {e}") }; return WinPatchState::Failed {
reason: format!("write winpeshl.ini: {e}"),
};
} }
let startnet = render_startnet(&self.smb_host, &self.smb_share); let startnet = render_startnet(&self.smb_host, &self.smb_share);
if let Err(e) = std::fs::write(staging.join("startnet.cmd"), startnet) { if let Err(e) = std::fs::write(staging.join("startnet.cmd"), startnet) {
return WinPatchState::Failed { reason: format!("write startnet.cmd: {e}") }; return WinPatchState::Failed {
reason: format!("write startnet.cmd: {e}"),
};
} }
// Build a wimlib update command file: // Build a wimlib update command file:
// add <stage>/Windows/System32 /Windows/System32 // add <stage>/Windows/System32 /Windows/System32
let update_file = work.path().join("update.cmd"); let update_file = work.path().join("update.cmd");
let update_cmd = format!( let update_cmd = format!("add \"{}\" \"/Windows/System32\"\n", staging.display());
"add \"{}\" \"/Windows/System32\"\n",
staging.display()
);
if let Err(e) = std::fs::write(&update_file, update_cmd) { if let Err(e) = std::fs::write(&update_file, update_cmd) {
return WinPatchState::Failed { reason: format!("write update.cmd: {e}") }; return WinPatchState::Failed {
reason: format!("write update.cmd: {e}"),
};
} }
// Run wimlib-imagex update against image index 2 (WinPE). // Run wimlib-imagex update against image index 2 (WinPE).
@@ -120,7 +132,9 @@ impl WimPatcher {
String::from_utf8_lossy(&o.stderr) String::from_utf8_lossy(&o.stderr)
), ),
}, },
Err(e) => WinPatchState::Failed { reason: format!("spawn wimlib-imagex: {e}") }, Err(e) => WinPatchState::Failed {
reason: format!("spawn wimlib-imagex: {e}"),
},
} }
} }
} }
@@ -133,7 +147,9 @@ fn which(cmd: &str) -> Option<PathBuf> {
let paths = std::env::var_os("PATH")?; let paths = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&paths) { for dir in std::env::split_paths(&paths) {
let p = dir.join(cmd); let p = dir.join(cmd);
if p.is_file() { return Some(p); } if p.is_file() {
return Some(p);
}
} }
None None
} }
@@ -152,18 +168,39 @@ const WINPESHL_INI: &str = "[LaunchApps]\r\n\
/// Uses CRLF line endings because WinPE cmd.exe requires them for .cmd files /// Uses CRLF line endings because WinPE cmd.exe requires them for .cmd files
/// created on unix hosts. /// created on unix hosts.
fn render_startnet(host: &str, share: &str) -> String { fn render_startnet(host: &str, share: &str) -> String {
let mut s = String::new(); use std::fmt::Write as _;
let host = host.trim(); let host = host.trim();
let share = share.trim_matches('/'); let share = share.trim_matches('/');
let mut s = String::new();
// Windows-style CRLF; consumed verbatim by cmd.exe inside WinPE.
// Bootimus v0.1.58 lesson: surface `net use` errors instead of
// tight-looping on a blind retry. We retry but log every miss.
s.push_str("@echo off\r\n"); s.push_str("@echo off\r\n");
s.push_str("echo PXEForge WinPE bootstrap\r\n"); s.push_str("echo OpenPXE WinPE bootstrap\r\n");
s.push_str("wpeinit\r\n"); s.push_str("wpeinit\r\n");
// v0.1.58: explicitly start Workstation before mapping the share —
// `net use` otherwise lazily inits SMB-client and races wpeinit.
s.push_str("net start Workstation >nul 2>&1\r\n");
s.push_str("ipconfig /renew\r\n"); s.push_str("ipconfig /renew\r\n");
s.push_str(&format!("echo Waiting for SMB server {host} to be reachable...\r\n")); writeln!(s, "echo Waiting for SMB server {host} to be reachable...\r").unwrap();
s.push_str(&format!(":waitsmb\r\nping -n 1 -w 500 {host} >nul && goto havenet\r\ntimeout /t 2 /nobreak >nul\r\ngoto waitsmb\r\n")); writeln!(
s,
":waitsmb\r\nping -n 1 -w 500 {host} >nul && goto havenet\r\n\
timeout /t 2 /nobreak >nul\r\ngoto waitsmb\r"
)
.unwrap();
s.push_str(":havenet\r\n"); s.push_str(":havenet\r\n");
s.push_str(&format!("echo Mapping install media from \\\\{host}\\{share}...\r\n")); writeln!(
s.push_str(&format!(":mapshare\r\nnet use Z: \\\\{host}\\{share} /user:guest \"\" /persistent:no && goto mapped\r\ntimeout /t 3 /nobreak >nul\r\ngoto mapshare\r\n")); s,
"echo Mapping install media from \\\\{host}\\{share}...\r"
)
.unwrap();
writeln!(
s,
":mapshare\r\nnet use Z: \\\\{host}\\{share} /user:guest \"\" /persistent:no && goto mapped\r\n\
timeout /t 3 /nobreak >nul\r\ngoto mapshare\r"
)
.unwrap();
s.push_str(":mapped\r\n"); s.push_str(":mapped\r\n");
s.push_str("echo Starting Windows Setup\r\n"); s.push_str("echo Starting Windows Setup\r\n");
s.push_str("Z:\\setup.exe\r\n"); s.push_str("Z:\\setup.exe\r\n");
@@ -188,6 +225,23 @@ mod tests {
assert!(s.contains("setup.exe")); assert!(s.contains("setup.exe"));
} }
#[test]
fn startnet_primes_workstation_and_surfaces_mapping_errors() {
let s = render_startnet("10.0.0.5", "win11");
assert!(
s.contains("net start Workstation"),
"WinPE should explicitly start the SMB client before net use:\n{s}"
);
let net_use_line = s
.lines()
.find(|line| line.contains("net use Z:"))
.expect("net use line");
assert!(
!net_use_line.contains(">nul"),
"net use errors must remain visible in WinPE console: {net_use_line}"
);
}
#[test] #[test]
fn patcher_reports_wimlib_missing_gracefully() { fn patcher_reports_wimlib_missing_gracefully() {
// We don't assume wimlib is present in CI; this checks the missing // We don't assume wimlib is present in CI; this checks the missing
@@ -1,5 +1,5 @@
[package] [package]
name = "pxeforge" name = "openpxe"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
@@ -10,16 +10,16 @@ description = "Container-native PXE boot server — a lightweight Rust clone of
workspace = true workspace = true
[[bin]] [[bin]]
name = "pxeforge" name = "openpxe"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
pxeforge-core.workspace = true openpxe-core.workspace = true
pxeforge-dhcp-proxy.workspace = true openpxe-dhcp-proxy.workspace = true
pxeforge-tftp.workspace = true openpxe-tftp.workspace = true
pxeforge-http-api.workspace = true openpxe-http-api.workspace = true
pxeforge-iso-store.workspace = true openpxe-iso-store.workspace = true
pxeforge-ipxe-assets.workspace = true openpxe-ipxe-assets.workspace = true
tokio.workspace = true tokio.workspace = true
axum.workspace = true axum.workspace = true
tracing.workspace = true tracing.workspace = true
@@ -1,24 +1,27 @@
//! PXEForge entry point. Wires the three protocol servers (DHCP proxy, //! OpenPXE entry point. Wires the three protocol servers (DHCP proxy,
//! TFTP, HTTP) to the shared ISO store and client registry, then runs //! TFTP, HTTP) to the shared ISO store and client registry, then runs
//! them concurrently. //! them concurrently.
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use pxeforge_core::{ClientRegistry, Config, DhcpMode, GateQueue, LogBus, LogBusLayer, SettingsStore}; use openpxe_core::{
use pxeforge_dhcp_proxy::DhcpProxyServer; ClientRegistry, Config, DeploymentQueue, DhcpMode, HostBindings, LogBus, LogBusLayer, Metrics,
use pxeforge_http_api::{build_router, AppState}; SettingsStore,
use pxeforge_iso_store::{IsoStore, NfsManager, SmbManager}; };
use std::sync::Arc; use openpxe_dhcp_proxy::DhcpProxyServer;
use pxeforge_tftp::TftpServer; use openpxe_http_api::{build_router, AppState};
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager};
use openpxe_tftp::TftpServer;
use std::net::{Ipv4Addr, SocketAddr}; use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
#[command(name = "pxeforge", about = "Container-native PXE boot server", version)] #[command(name = "openpxe", about = "Container-native PXE boot server", version)]
struct Cli { struct Cli {
/// Path to a TOML config file. All fields have sensible defaults and can /// Path to a TOML config file. All fields have sensible defaults and can
/// also be overridden with env vars (PXEFORGE_*). /// also be overridden with env vars (OPENPXE_*).
#[arg(long, env = "PXEFORGE_CONFIG")] #[arg(long, env = "OPENPXE_CONFIG")]
config: Option<PathBuf>, config: Option<PathBuf>,
#[command(subcommand)] #[command(subcommand)]
@@ -35,8 +38,8 @@ enum Command {
/// Example: /// Example:
/// docker run --rm \ /// docker run --rm \
/// -v /my/isos:/seed:ro \ /// -v /my/isos:/seed:ro \
/// -v pxeforge-data:/var/lib/pxeforge/isos \ /// -v openpxe-data:/var/lib/openpxe/isos \
/// pxeforge:0.1.0 seed --from /seed /// openpxe:0.4.1 seed --from /seed
Seed { Seed {
/// Source directory containing one or more `.iso` files. /// Source directory containing one or more `.iso` files.
#[arg(long)] #[arg(long)]
@@ -67,7 +70,7 @@ async fn main() -> anyhow::Result<()> {
return run_command(cmd, config).await; return run_command(cmd, config).await;
} }
pxeforge_ipxe_assets::log_availability(); openpxe_ipxe_assets::log_availability();
let our_ip = match config.server.public_ip { let our_ip = match config.server.public_ip {
Some(ip) => { Some(ip) => {
@@ -85,7 +88,7 @@ async fn main() -> anyhow::Result<()> {
// message instead of serving a broken deployment. // message instead of serving a broken deployment.
anyhow::bail!( anyhow::bail!(
"could not detect a non-loopback IPv4 address for this host. \ "could not detect a non-loopback IPv4 address for this host. \
Set PXEFORGE_PUBLIC_IP=<your-ip> (e.g. `-e PXEFORGE_PUBLIC_IP=10.0.0.5` \ Set OPENPXE_PUBLIC_IP=<your-ip> (e.g. `-e OPENPXE_PUBLIC_IP=10.0.0.5` \
in docker, or the env block in OpenShift Deployment) to advertise \ in docker, or the env block in OpenShift Deployment) to advertise \
a specific IP to PXE clients." a specific IP to PXE clients."
); );
@@ -96,9 +99,27 @@ async fn main() -> anyhow::Result<()> {
let iso_store = IsoStore::new(config.paths.iso_dir.clone()); let iso_store = IsoStore::new(config.paths.iso_dir.clone());
iso_store.load_from_disk().await?; iso_store.load_from_disk().await?;
// v0.5.2: unattended answer-file store (Kickstart/Preseed/Autoinstall/
// Windows answer files). Separate directory from the ISO store.
let unattended =
openpxe_iso_store::UnattendedStore::new(config.paths.unattended_dir.clone());
if let Err(e) = unattended.load_from_disk().await {
tracing::warn!(
target: "openpxe::unattended",
"could not load unattended files on startup: {e}"
);
}
let clients = ClientRegistry::new(); let clients = ClientRegistry::new();
let gates = GateQueue::new(); let queue = DeploymentQueue::new();
let settings = SettingsStore::load_or_default(&config.paths.work_dir); let settings = SettingsStore::load_or_default(&config.paths.work_dir);
let hosts = HostBindings::load_or_default(&config.paths.work_dir);
let boot_log = openpxe_core::BootLog::load_or_default(&config.paths.work_dir);
let branding = openpxe_core::BrandingStore::load_or_default(&config.paths.work_dir);
let admin = openpxe_core::AdminStore::load_or_default(&config.paths.work_dir);
let 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();
// Build the SMB manager unconditionally — it starts/stops on the // Build the SMB manager unconditionally — it starts/stops on the
// Windows toggle, not at process start. If the `smb_dir` isn't // Windows toggle, not at process start. If the `smb_dir` isn't
@@ -109,13 +130,30 @@ async fn main() -> anyhow::Result<()> {
let _ = smb.start(); let _ = smb.start();
} }
// NFS manager. The mount root has to be set on the IsoStore *before* // v0.4.65: SMB share manager — Samba `smbclient` userspace
// we replay any persisted mounts, otherwise an in-memory IsoMeta // consumer. Replaces the kernel-mount NFS path that v0.4.64
// pointing at an NFS source can't resolve to a path. // shipped; that didn't work on hosts whose kernel lacked the nfs
let nfs = NfsManager::new(&config.paths.work_dir, iso_store.clone()); // client modules (Unraid is the dominant case). `smbclient` does
iso_store.set_nfs_root(nfs.mount_root()); // the SMB protocol entirely in userspace over TCP and works in
if let Err(e) = nfs.load_and_remount().await { // any container regardless of capabilities or kernel modules.
tracing::warn!(target: "pxeforge::nfs", "could not reload NFS mounts: {e}"); let smb_shares = SmbShareManager::new(&config.paths.work_dir, iso_store.clone());
if let Err(e) = smb_shares.load_and_rescan().await {
tracing::warn!(
target: "openpxe::smb",
"could not reload SMB shares on startup: {e}"
);
}
// v0.4.67: NFSv3 share manager — pure-Rust in-process consumer
// via the `nfs3_client` crate. Sits alongside the SMB manager;
// operators pick whichever protocol their NAS prefers, or use
// both. No subprocess, no kernel mount, works in any container.
let nfs_shares = NfsShareManager::new(&config.paths.work_dir, iso_store.clone());
if let Err(e) = nfs_shares.load_and_rescan().await {
tracing::warn!(
target: "openpxe::nfs",
"could not reload NFS shares on startup: {e}"
);
} }
// Sniff network details for the Network tab. None of these are // Sniff network details for the Network tab. None of these are
@@ -124,7 +162,7 @@ async fn main() -> anyhow::Result<()> {
// own gateway. // own gateway.
let net = detect_network_info(our_ip); let net = detect_network_info(our_ip);
tracing::info!( tracing::info!(
target: "pxeforge::net", target: "openpxe::net",
nic = %net.nic_name, mask = %net.subnet_mask, gateway = %net.gateway, nic = %net.nic_name, mask = %net.subnet_mask, gateway = %net.gateway,
"network info" "network info"
); );
@@ -133,9 +171,21 @@ async fn main() -> anyhow::Result<()> {
iso_store: iso_store.clone(), iso_store: iso_store.clone(),
clients: clients.clone(), clients: clients.clone(),
settings: settings.clone(), settings: settings.clone(),
gates: gates.clone(), queue: queue.clone(),
hosts: hosts.clone(),
boot_log: boot_log.clone(),
branding: branding.clone(),
admin: admin.clone(),
sessions: sessions.clone(),
sso: sso.clone(),
saml: openpxe_http_api::saml_routes::SamlRuntime::default(),
notify: notify.clone(),
metrics: metrics.clone(),
smb: Some(smb.clone()), smb: Some(smb.clone()),
nfs: nfs.clone(), smb_shares: smb_shares.clone(),
nfs_shares: nfs_shares.clone(),
unattended: unattended.clone(),
uploads: openpxe_http_api::uploads::UploadSessions::default(),
log_bus: log_bus.clone(), log_bus: log_bus.clone(),
started_at: time::OffsetDateTime::now_utc(), started_at: time::OffsetDateTime::now_utc(),
public_base_url: public_base_url.clone(), public_base_url: public_base_url.clone(),
@@ -148,12 +198,25 @@ async fn main() -> anyhow::Result<()> {
let router = build_router(state); let router = build_router(state);
let http_task = tokio::spawn(async move { let http_task = tokio::spawn(async move {
let listener = tokio::net::TcpListener::bind(http_addr).await?; let listener = tokio::net::TcpListener::bind(http_addr).await?;
tracing::info!(target: "pxeforge::http", "HTTP listening on {http_addr}"); tracing::info!(target: "openpxe::http", "HTTP listening on {http_addr}");
axum::serve(listener, router).await?; // `into_make_service_with_connect_info` is required so per-request
// `ConnectInfo<SocketAddr>` extractors can resolve the peer IP —
// used by `/boot/<entry>.ipxe` to record the booting client's
// address into the Host log. Without this the extractor 500s.
axum::serve(
listener,
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await?;
Ok::<_, anyhow::Error>(()) Ok::<_, anyhow::Error>(())
}); });
let tftp = TftpServer::new(config.server.tftp_bind, config.server.tftp_port, clients.clone()); let tftp = TftpServer::new(
config.server.tftp_bind,
config.server.tftp_port,
clients.clone(),
metrics.clone(),
);
let tftp_task = tokio::spawn(tftp.run()); let tftp_task = tokio::spawn(tftp.run());
let dhcp_task: tokio::task::JoinHandle<anyhow::Result<()>> = match config.network.dhcp_mode { let dhcp_task: tokio::task::JoinHandle<anyhow::Result<()>> = match config.network.dhcp_mode {
@@ -165,11 +228,12 @@ async fn main() -> anyhow::Result<()> {
our_ip, our_ip,
public_base_url.clone(), public_base_url.clone(),
clients.clone(), clients.clone(),
metrics.clone(),
); );
tokio::spawn(s.run()) tokio::spawn(s.run())
} }
DhcpMode::Disabled => { DhcpMode::Disabled => {
tracing::info!(target: "pxeforge::dhcp", "DHCP disabled — external DHCP must set next-server + filename"); tracing::info!(target: "openpxe::dhcp", "DHCP disabled — external DHCP must set next-server + filename");
tokio::spawn(async { futures_forever().await }) tokio::spawn(async { futures_forever().await })
} }
}; };
@@ -197,7 +261,11 @@ async fn run_command(cmd: Command, config: Config) -> anyhow::Result<()> {
/// Reuses `IsoStore::begin_upload` / `finish` so the resulting meta on disk /// Reuses `IsoStore::begin_upload` / `finish` so the resulting meta on disk
/// is identical to a web upload — same slug rules, same introspection, same /// is identical to a web upload — same slug rules, same introspection, same
/// sha256. /// sha256.
async fn seed_from_dir(src: &std::path::Path, config: &Config, dry_run: bool) -> anyhow::Result<()> { async fn seed_from_dir(
src: &std::path::Path,
config: &Config,
dry_run: bool,
) -> anyhow::Result<()> {
let store = IsoStore::new(config.paths.iso_dir.clone()); let store = IsoStore::new(config.paths.iso_dir.clone());
store.load_from_disk().await?; store.load_from_disk().await?;
let mut entries = tokio::fs::read_dir(src).await?; let mut entries = tokio::fs::read_dir(src).await?;
@@ -205,7 +273,12 @@ async fn seed_from_dir(src: &std::path::Path, config: &Config, dry_run: bool) ->
let mut skipped = 0u32; let mut skipped = 0u32;
while let Some(entry) = entries.next_entry().await? { while let Some(entry) = entries.next_entry().await? {
let p = entry.path(); let p = entry.path();
if p.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() != Some("iso") { if p.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
!= Some("iso")
{
continue; continue;
} }
let filename = p let filename = p
@@ -213,12 +286,18 @@ async fn seed_from_dir(src: &std::path::Path, config: &Config, dry_run: bool) ->
.and_then(|s| s.to_str()) .and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("non-utf8 filename: {}", p.display()))? .ok_or_else(|| anyhow::anyhow!("non-utf8 filename: {}", p.display()))?
.to_string(); .to_string();
println!(" {} ({} bytes)", filename, tokio::fs::metadata(&p).await?.len()); println!(
if dry_run { continue; } " {} ({} bytes)",
filename,
tokio::fs::metadata(&p).await?.len()
);
if dry_run {
continue;
}
let mut handle = match store.begin_upload(&filename).await { let mut handle = match store.begin_upload(&filename).await {
Ok(h) => h, Ok(h) => h,
Err(pxeforge_core::Error::Invalid(e)) => { Err(openpxe_core::Error::Invalid(e)) => {
eprintln!(" skip: {e}"); eprintln!(" skip: {e}");
skipped += 1; skipped += 1;
continue; continue;
@@ -229,15 +308,23 @@ async fn seed_from_dir(src: &std::path::Path, config: &Config, dry_run: bool) ->
let mut buf = vec![0u8; 1024 * 1024]; let mut buf = vec![0u8; 1024 * 1024];
loop { loop {
let n = file.read(&mut buf).await?; let n = file.read(&mut buf).await?;
if n == 0 { break; } if n == 0 {
break;
}
let chunk: bytes::Bytes = buf[..n].to_vec().into(); let chunk: bytes::Bytes = buf[..n].to_vec().into();
handle.write_chunk(&chunk).await?; handle.write_chunk(&chunk).await?;
} }
let meta = handle.finish(&store).await?; let meta = handle.finish(&store).await?;
println!(" -> id={} family={:?}", meta.id, meta.introspection.family); println!(
" -> id={} family={:?}",
meta.id, meta.introspection.family
);
imported += 1; imported += 1;
} }
println!("\nimported={imported} skipped={skipped} {}", if dry_run { "(dry run)" } else { "" }); println!(
"\nimported={imported} skipped={skipped} {}",
if dry_run { "(dry run)" } else { "" }
);
Ok(()) Ok(())
} }
@@ -245,7 +332,7 @@ async fn seed_from_dir(src: &std::path::Path, config: &Config, dry_run: bool) ->
/// detection fails — callers should fail startup rather than silently using /// detection fails — callers should fail startup rather than silently using
/// a loopback address (which would give every PXE client an unreachable /// a loopback address (which would give every PXE client an unreachable
/// `http://127.0.0.1/...`). Users in multi-homed setups should set /// `http://127.0.0.1/...`). Users in multi-homed setups should set
/// `PXEFORGE_PUBLIC_IP` explicitly. /// `OPENPXE_PUBLIC_IP` explicitly.
fn detect_primary_ipv4() -> Option<Ipv4Addr> { fn detect_primary_ipv4() -> Option<Ipv4Addr> {
// First try: route to the public internet. `UdpSocket::connect` to a // First try: route to the public internet. `UdpSocket::connect` to a
// well-known external address causes the OS to populate `local_addr` // well-known external address causes the OS to populate `local_addr`
@@ -281,15 +368,14 @@ fn hostname() -> std::io::Result<String> {
if let Ok(h) = std::fs::read_to_string("/proc/sys/kernel/hostname") { if let Ok(h) = std::fs::read_to_string("/proc/sys/kernel/hostname") {
return Ok(h.trim().to_string()); return Ok(h.trim().to_string());
} }
std::env::var("HOSTNAME").map_err(|_| std::io::Error::new( std::env::var("HOSTNAME")
std::io::ErrorKind::NotFound, "no hostname", .map_err(|_| std::io::Error::new(std::io::ErrorKind::NotFound, "no hostname"))
))
} }
fn init_tracing(bus: Arc<LogBus>) { fn init_tracing(bus: Arc<LogBus>) {
use tracing_subscriber::{fmt, prelude::*, EnvFilter}; use tracing_subscriber::{fmt, prelude::*, EnvFilter};
let filter = EnvFilter::try_from_env("PXEFORGE_LOG") let filter = EnvFilter::try_from_env("OPENPXE_LOG")
.unwrap_or_else(|_| EnvFilter::new("info,pxeforge=debug")); .unwrap_or_else(|_| EnvFilter::new("info,openpxe=debug"));
tracing_subscriber::registry() tracing_subscriber::registry()
.with(filter) .with(filter)
.with(fmt::layer().with_target(true)) .with(fmt::layer().with_target(true))
@@ -315,7 +401,10 @@ fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo {
// `ip -o -f inet addr show` lists every interface with its // `ip -o -f inet addr show` lists every interface with its
// `inet a.b.c.d/mask`. We match the line that mentions our IP. // `inet a.b.c.d/mask`. We match the line that mentions our IP.
if let Ok(out) = Command::new("ip").args(["-o", "-f", "inet", "addr", "show"]).output() { if let Ok(out) = Command::new("ip")
.args(["-o", "-f", "inet", "addr", "show"])
.output()
{
if let Ok(text) = String::from_utf8(out.stdout) { if let Ok(text) = String::from_utf8(out.stdout) {
for line in text.lines() { for line in text.lines() {
if !line.contains(&our_ip.to_string()) { if !line.contains(&our_ip.to_string()) {
@@ -340,7 +429,10 @@ fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo {
} }
// `ip route show default` -> "default via 10.0.0.1 dev enp1s0 ..." // `ip route show default` -> "default via 10.0.0.1 dev enp1s0 ..."
if let Ok(out) = Command::new("ip").args(["route", "show", "default"]).output() { if let Ok(out) = Command::new("ip")
.args(["route", "show", "default"])
.output()
{
if let Ok(text) = String::from_utf8(out.stdout) { if let Ok(text) = String::from_utf8(out.stdout) {
if let Some(line) = text.lines().next() { if let Some(line) = text.lines().next() {
let mut parts = line.split_whitespace(); let mut parts = line.split_whitespace();
@@ -361,7 +453,11 @@ fn detect_network_info(our_ip: Ipv4Addr) -> NetworkInfo {
fn prefix_to_dotted(prefix: u8) -> String { fn prefix_to_dotted(prefix: u8) -> String {
let prefix = prefix.min(32); let prefix = prefix.min(32);
let mask: u32 = if prefix == 0 { 0 } else { u32::MAX << (32 - prefix) }; let mask: u32 = if prefix == 0 {
0
} else {
u32::MAX << (32 - prefix)
};
format!( format!(
"{}.{}.{}.{}", "{}.{}.{}.{}",
(mask >> 24) & 0xff, (mask >> 24) & 0xff,
+3 -3
View File
@@ -1,5 +1,5 @@
[package] [package]
name = "pxeforge-tftp" name = "openpxe-tftp"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
@@ -10,8 +10,8 @@ description = "TFTP server (RFC 1350/2347/2348/2349/7440) for iPXE chainload"
workspace = true workspace = true
[dependencies] [dependencies]
pxeforge-core.workspace = true openpxe-core.workspace = true
pxeforge-ipxe-assets.workspace = true openpxe-ipxe-assets.workspace = true
tokio.workspace = true tokio.workspace = true
socket2.workspace = true socket2.workspace = true
tracing.workspace = true tracing.workspace = true
+86 -35
View File
@@ -7,12 +7,12 @@
//! `tftpd`/`in.tftpd` works and is why TFTP is awkward behind stateful NAT: //! `tftpd`/`in.tftpd` works and is why TFTP is awkward behind stateful NAT:
//! the ephemeral ports must be reachable from the client. //! the ephemeral ports must be reachable from the client.
//! //!
//! We only serve files from `pxeforge_ipxe_assets::asset_bytes` — that is, //! We only serve files from `openpxe_ipxe_assets::asset_bytes` — that is,
//! the bundled iPXE binaries and wimboot. No filesystem is ever opened, so //! the bundled iPXE binaries and wimboot. No filesystem is ever opened, so
//! `../` path traversal attempts simply return ENOENT. //! `../` path traversal attempts simply return ENOENT.
use pxeforge_core::{ClientEvent, ClientRegistry}; use openpxe_core::{ClientEvent, ClientRegistry};
use pxeforge_ipxe_assets::asset_bytes; use openpxe_ipxe_assets::asset_bytes;
use socket2::{Domain, Protocol, Socket, Type}; use socket2::{Domain, Protocol, Socket, Type};
use std::net::{IpAddr, SocketAddr}; use std::net::{IpAddr, SocketAddr};
use std::sync::Arc; use std::sync::Arc;
@@ -35,32 +35,46 @@ pub struct TftpServer {
bind: IpAddr, bind: IpAddr,
port: u16, port: u16,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
} }
impl TftpServer { impl TftpServer {
pub fn new(bind: IpAddr, port: u16, clients: Arc<ClientRegistry>) -> Self { pub fn new(
Self { bind, port, clients } bind: IpAddr,
port: u16,
clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
) -> Self {
Self {
bind,
port,
clients,
metrics,
}
} }
pub async fn run(self) -> anyhow::Result<()> { pub async fn run(self) -> anyhow::Result<()> {
let sock = bind_udp(self.bind, self.port)?; let sock = bind_udp(self.bind, self.port)?;
tracing::info!(target: "pxeforge::tftp", "TFTP listening on {}:{}", self.bind, self.port); tracing::info!(target: "openpxe::tftp", "TFTP listening on {}:{}", self.bind, self.port);
let clients = self.clients.clone(); let clients = self.clients.clone();
let metrics = self.metrics.clone();
let mut buf = vec![0u8; 2048]; let mut buf = vec![0u8; 2048];
loop { loop {
let (n, from) = match sock.recv_from(&mut buf).await { let (n, from) = match sock.recv_from(&mut buf).await {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
tracing::warn!(target: "pxeforge::tftp", "recv error: {e}"); tracing::warn!(target: "openpxe::tftp", "recv error: {e}");
continue; continue;
} }
}; };
let data = buf[..n].to_vec(); let data = buf[..n].to_vec();
let clients = clients.clone(); let clients = clients.clone();
let metrics = metrics.clone();
let bind_ip = self.bind; let bind_ip = self.bind;
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = handle_rrq(data, from, bind_ip, clients).await { if let Err(e) = handle_rrq(data, from, bind_ip, clients, metrics.clone()).await {
tracing::warn!(target: "pxeforge::tftp", peer=%from, "handler error: {e}"); metrics.record_tftp_err();
tracing::warn!(target: "openpxe::tftp", peer=%from, "handler error: {e}");
} }
}); });
} }
@@ -72,30 +86,34 @@ async fn handle_rrq(
peer: SocketAddr, peer: SocketAddr,
bind_ip: IpAddr, bind_ip: IpAddr,
clients: Arc<ClientRegistry>, clients: Arc<ClientRegistry>,
metrics: openpxe_core::Metrics,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let req = match parse_rrq(&packet) { let Some(req) = parse_rrq(&packet) else {
Some(r) => r, return Ok(());
None => return Ok(()),
}; };
let Request { filename, options, .. } = req; let Request {
filename, options, ..
} = req;
// Per-transfer ephemeral socket. // Per-transfer ephemeral socket.
let sock = bind_udp(bind_ip, 0)?; let sock = bind_udp(bind_ip, 0)?;
let Some(file_bytes) = asset_bytes(&filename) else { let Some(file_bytes) = asset_bytes(&filename) else {
let _ = send_error(&sock, peer, ERR_FILE_NOT_FOUND, "no such file").await; let _ = send_error(&sock, peer, ERR_FILE_NOT_FOUND, "no such file").await;
tracing::info!(target: "pxeforge::tftp", peer=%peer, file=%filename, "404"); tracing::info!(target: "openpxe::tftp", peer=%peer, file=%filename, "404");
clients.record( clients.record(
&peer.ip().to_string(), &peer.ip().to_string(),
Some(peer.ip()), Some(peer.ip()),
None, None,
ClientEvent::TftpRead { file: filename.clone() }, ClientEvent::TftpRead {
file: filename.clone(),
},
); );
return Ok(()); return Ok(());
}; };
tracing::info!( tracing::info!(
target: "pxeforge::tftp", target: "openpxe::tftp",
peer=%peer, file=%filename, size=file_bytes.len(), peer=%peer, file=%filename, size=file_bytes.len(),
"serving" "serving"
); );
@@ -103,7 +121,9 @@ async fn handle_rrq(
&peer.ip().to_string(), &peer.ip().to_string(),
Some(peer.ip()), Some(peer.ip()),
None, None,
ClientEvent::TftpRead { file: filename.clone() }, ClientEvent::TftpRead {
file: filename.clone(),
},
); );
// Negotiate options. // Negotiate options.
@@ -149,7 +169,11 @@ async fn handle_rrq(
let total = file_bytes.len(); let total = file_bytes.len();
let mut offset: usize = 0; let mut offset: usize = 0;
let mut block_no: u16 = 1; let mut block_no: u16 = 1;
let mut needs_zero_final = false; // spec: if last data block == blksize, follow with empty DATA // Per RFC 1350: if the final data block is exactly blksize, the
// server must follow up with a zero-length DATA so the client knows
// the transfer has ended. The flag is set inside the loop and
// tested at end-of-transfer.
let needs_zero_final;
'transfer: loop { 'transfer: loop {
let window_start_offset = offset; let window_start_offset = offset;
@@ -160,7 +184,9 @@ async fn handle_rrq(
// Send one window worth of DATA. // Send one window worth of DATA.
for _ in 0..window { for _ in 0..window {
if offset >= total { break; } if offset >= total {
break;
}
let end = (offset + blksize).min(total); let end = (offset + blksize).min(total);
let chunk = &file_bytes[offset..end]; let chunk = &file_bytes[offset..end];
let pkt = encode_data(block_no, chunk); let pkt = encode_data(block_no, chunk);
@@ -181,13 +207,13 @@ async fn handle_rrq(
loop { loop {
match tokio::time::timeout(Duration::from_secs(3), recv_ack(&sock, peer)).await { match tokio::time::timeout(Duration::from_secs(3), recv_ack(&sock, peer)).await {
Ok(Ok(acked)) if acked == last_block_in_window => break, Ok(Ok(acked)) if acked == last_block_in_window => break,
Ok(Ok(_)) => continue, // stale ACK from an earlier block — ignore Ok(Ok(_)) => {} // stale ACK from an earlier block — ignore
Ok(Err(e)) => return Err(e), Ok(Err(e)) => return Err(e),
Err(_) => { Err(_) => {
tries += 1; tries += 1;
if tries > 5 { if tries > 5 {
tracing::warn!( tracing::warn!(
target: "pxeforge::tftp", target: "openpxe::tftp",
peer=%peer, last_block=last_block_in_window, peer=%peer, last_block=last_block_in_window,
"timeout after {tries} retries, aborting transfer" "timeout after {tries} retries, aborting transfer"
); );
@@ -228,7 +254,8 @@ async fn handle_rrq(
let _ = tokio::time::timeout(Duration::from_secs(3), recv_ack(&sock, peer)).await; let _ = tokio::time::timeout(Duration::from_secs(3), recv_ack(&sock, peer)).await;
} }
tracing::debug!(target: "pxeforge::tftp", peer=%peer, bytes=total, "transfer complete"); tracing::debug!(target: "openpxe::tftp", peer=%peer, bytes=total, "transfer complete");
metrics.record_tftp_ok(total as u64);
Ok(()) Ok(())
} }
@@ -241,20 +268,30 @@ struct Request {
} }
fn parse_rrq(pkt: &[u8]) -> Option<Request> { fn parse_rrq(pkt: &[u8]) -> Option<Request> {
if pkt.len() < 4 { return None; } if pkt.len() < 4 {
return None;
}
let op = u16::from_be_bytes([pkt[0], pkt[1]]); let op = u16::from_be_bytes([pkt[0], pkt[1]]);
if op != OP_RRQ { return None; } if op != OP_RRQ {
return None;
}
let mut rest = &pkt[2..]; let mut rest = &pkt[2..];
let filename = read_cstr(&mut rest)?; let filename = read_cstr(&mut rest)?;
let mode = read_cstr(&mut rest)?; let mode = read_cstr(&mut rest)?;
let mut options = Vec::new(); let mut options = Vec::new();
while !rest.is_empty() { while !rest.is_empty() {
let k = match read_cstr(&mut rest) { Some(s) => s, None => break }; let Some(k) = read_cstr(&mut rest) else { break };
if k.is_empty() { break; } if k.is_empty() {
break;
}
let v = read_cstr(&mut rest).unwrap_or_default(); let v = read_cstr(&mut rest).unwrap_or_default();
options.push((k.to_ascii_lowercase(), v)); options.push((k.to_ascii_lowercase(), v));
} }
Some(Request { filename, mode, options }) Some(Request {
filename,
mode,
options,
})
} }
fn read_cstr(buf: &mut &[u8]) -> Option<String> { fn read_cstr(buf: &mut &[u8]) -> Option<String> {
@@ -302,8 +339,12 @@ async fn recv_ack(sock: &UdpSocket, peer: SocketAddr) -> anyhow::Result<u16> {
let mut buf = [0u8; 32]; let mut buf = [0u8; 32];
loop { loop {
let (n, from) = sock.recv_from(&mut buf).await?; let (n, from) = sock.recv_from(&mut buf).await?;
if from.ip() != peer.ip() { continue; } if from.ip() != peer.ip() {
if n < 4 { continue; } continue;
}
if n < 4 {
continue;
}
let op = u16::from_be_bytes([buf[0], buf[1]]); let op = u16::from_be_bytes([buf[0], buf[1]]);
match op { match op {
OP_ACK => return Ok(u16::from_be_bytes([buf[2], buf[3]])), OP_ACK => return Ok(u16::from_be_bytes([buf[2], buf[3]])),
@@ -311,7 +352,7 @@ async fn recv_ack(sock: &UdpSocket, peer: SocketAddr) -> anyhow::Result<u16> {
let code = u16::from_be_bytes([buf[2], buf[3]]); let code = u16::from_be_bytes([buf[2], buf[3]]);
anyhow::bail!("client error {code}"); anyhow::bail!("client error {code}");
} }
_ => continue, _ => {}
} }
} }
} }
@@ -327,17 +368,22 @@ async fn wait_for_ack(
sock.send_to(to_retx, peer).await?; sock.send_to(to_retx, peer).await?;
match tokio::time::timeout(Duration::from_secs(3), recv_ack(sock, peer)).await { match tokio::time::timeout(Duration::from_secs(3), recv_ack(sock, peer)).await {
Ok(Ok(b)) if b == expect_block => return Ok(true), Ok(Ok(b)) if b == expect_block => return Ok(true),
Ok(Ok(_)) => continue, Ok(Ok(_)) => {}
Ok(Err(_)) | Err(_) => { Ok(Err(_)) | Err(_) => {
tries += 1; tries += 1;
if tries > 5 { return Ok(false); } if tries > 5 {
return Ok(false);
}
} }
} }
} }
} }
fn bind_udp(bind: IpAddr, port: u16) -> anyhow::Result<UdpSocket> { fn bind_udp(bind: IpAddr, port: u16) -> anyhow::Result<UdpSocket> {
let domain = match bind { IpAddr::V4(_) => Domain::IPV4, IpAddr::V6(_) => Domain::IPV6 }; let domain = match bind {
IpAddr::V4(_) => Domain::IPV4,
IpAddr::V6(_) => Domain::IPV6,
};
let sock = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP))?; let sock = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP))?;
sock.set_reuse_address(true)?; sock.set_reuse_address(true)?;
sock.set_nonblocking(true)?; sock.set_nonblocking(true)?;
@@ -368,7 +414,9 @@ pub fn plan_window(
let mut o = offset; let mut o = offset;
let mut b = starting_block; let mut b = starting_block;
for _ in 0..window { for _ in 0..window {
if o >= total { break; } if o >= total {
break;
}
let end = (o + blksize).min(total); let end = (o + blksize).min(total);
out.push((b, end - o)); out.push((b, end - o));
o = end; o = end;
@@ -385,7 +433,10 @@ mod tests {
fn parses_rrq_with_options() { fn parses_rrq_with_options() {
// RRQ "snponly.efi" mode "octet" blksize=1468 tsize=0 // RRQ "snponly.efi" mode "octet" blksize=1468 tsize=0
let mut pkt = vec![0, OP_RRQ as u8]; let mut pkt = vec![0, OP_RRQ as u8];
pkt.extend_from_slice(b"snponly.efi\0octet\0blksize\01468\0tsize\00\0"); // The single-digit `\0` escapes here are NUL terminators between
// TFTP option name/value pairs — using `\x00` to dodge clippy's
// "octal-looking escape" lint.
pkt.extend_from_slice(b"snponly.efi\x00octet\x00blksize\x001468\x00tsize\x000\x00");
let r = parse_rrq(&pkt).unwrap(); let r = parse_rrq(&pkt).unwrap();
assert_eq!(r.filename, "snponly.efi"); assert_eq!(r.filename, "snponly.efi");
assert_eq!(r.mode, "octet"); assert_eq!(r.mode, "octet");
+2 -2
View File
@@ -1,10 +1,10 @@
[package] [package]
name = "pxeforge-webui" name = "openpxe-webui"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
description = "Embedded single-file web UI for PXEForge" description = "Embedded single-file web UI for OpenPXE"
[lints] [lints]
workspace = true workspace = true
+621 -86
View File
@@ -1,28 +1,74 @@
/* PXEForge web UI Netbox-style layout, fully offline. /* OpenPXE web UI Netbox-style minimal layout, fully offline.
* Design tokens are CSS variables so a later phase can re-theme without *
* touching markup or JS. */ * Theme tokens live on `:root` (dark default) and `:root[data-theme=light]`.
* Both palettes share variable *names*, so component CSS uses
* `var(--bg)` regardless and the toggle in the topbar just flips the
* data-attribute. No JS-side recolouring, no React re-renders, no FOUC
* (the inline script in index.html paints the right theme before main
* CSS lands). */
:root { :root {
--bg: #0b1018; /* Jet-black dark palette (default). Modelled on Netbox Labs's
--bg-panel: #121826; near-black product chrome, with surfaces stepping subtly upward
--bg-panel-2: #1a2334; rather than the previous blue-tinted ramp, so the UI reads as a
--bg-elev: #223047; genuine "dark" rather than "dim navy". */
--fg: #e4e8ef; --bg: #030303;
--fg-dim: #8a94a7; --bg-panel: #0a0a0a;
--fg-dimmer: #5a6379; --bg-panel-2: #141414;
--accent: #00d4b4; /* Netbox-ish teal */ --bg-elev: #1c1c1c;
--fg: #e8eaed;
--fg-dim: #9aa0a6;
--fg-dimmer: #6b7077;
--accent: #00d4b4; /* Netbox-ish teal — kept for brand */
--accent-dim: #07a38c; --accent-dim: #07a38c;
--warn: #ffb347; --warn: #ffb347;
--err: #ef6e6e; --err: #ef6e6e;
--ok: #4ade80; --ok: #4ade80;
--border: #223047; --border: #1f1f1f;
--border-soft: #172033; --border-soft: #141414;
--terminal-bg: #050505;
--shadow-card: 0 1px 0 rgba(255,255,255,0.02), 0 8px 24px rgba(0,0,0,0.55);
--radius: 6px; --radius: 6px;
--radius-lg: 10px; --radius-lg: 10px;
--sidebar-w: 240px; --sidebar-w: 240px;
--topbar-h: 54px; --topbar-h: 56px;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
/* v0.4.63: tie native form-control rendering (checkboxes, scroll bars,
date pickers) to the active OpenPXE theme. Without this, the inline
`<meta name="color-scheme" content="dark light">` in index.html forces
dark form chrome in *both* themes so the SSO "Enable single sign-on"
checkbox renders as an opaque black square against the light-mode
panel, ignoring our accent-color hint. CSS `color-scheme` overrides
the meta and tracks `data-theme` correctly. */
color-scheme: dark;
}
:root[data-theme="light"] {
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. */
--bg: #f6f8fb;
--bg-panel: #fbfcfe;
--bg-panel-2: #f0f3f8;
--bg-elev: #e6ebf2;
--fg: #1c2330;
--fg-dim: #5a6377;
--fg-dimmer: #95a0b3;
--accent: #00b89c;
--accent-dim: #008b73;
--warn: #b67016;
--err: #c63a3a;
--ok: #1f9b54;
--border: #d8dde6;
--border-soft: #e7eaf0;
/* Light-mode terminal: the pane background and chrome track the rest
of the light theme. Per-level text colours below recolour-on-light
so log lines stay readable on a pale background previously the
terminal was locked to dark and looked like a stuck panel. */
--terminal-bg: #ffffff;
--shadow-card: 0 1px 0 rgba(0,0,0,0.02), 0 6px 18px rgba(20,28,52,0.06);
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -30,6 +76,7 @@ html, body { height: 100%; }
body { body {
margin: 0; font-family: var(--sans); font-size: 14px; line-height: 1.5; margin: 0; font-family: var(--sans); font-size: 14px; line-height: 1.5;
background: var(--bg); color: var(--fg); background: var(--bg); color: var(--fg);
transition: background 0.16s ease, color 0.16s ease;
} }
a { color: var(--accent); text-decoration: none; } a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
@@ -53,24 +100,43 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
display: flex; flex-direction: column; display: flex; flex-direction: column;
} }
/* The brand block sits flush with the topbar so the sidebar+topbar reads
as one continuous bar across the top of the app, rather than a chunky
2-line logo block plus a separate (smaller-typeface) page title. The
height/border-bottom match the topbar exactly so the divider runs
straight across without a step. */
.sidebar .brand { .sidebar .brand {
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 12px;
padding: 14px 18px; padding: 0 18px;
height: var(--topbar-h);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.sidebar .brand img { width: 40px; height: auto; } .sidebar .brand img { width: 26px; height: 26px; flex: none; }
.sidebar .brand strong { font-size: 16px; letter-spacing: 0.4px; } .sidebar .brand strong {
.sidebar .brand .sub { color: var(--fg-dim); font-size: 11px; } font-size: 15px; font-weight: 600;
.sidebar nav { padding: 10px 0; flex: 1; overflow-y: auto; } letter-spacing: 0.2px;
.sidebar nav .group { color: var(--fg);
padding: 10px 18px 6px;
font-size: 10.5px; color: var(--fg-dimmer); text-transform: uppercase;
letter-spacing: 1px;
} }
/* 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 { .sidebar nav a {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
padding: 7px 18px; color: var(--fg); font-size: 13.5px; padding: 8px 18px; color: var(--fg); font-size: 13.5px;
border-left: 2px solid transparent; border-left: 2px solid transparent;
cursor: pointer;
} }
.sidebar nav a:hover { background: var(--bg-panel-2); text-decoration: none; } .sidebar nav a:hover { background: var(--bg-panel-2); text-decoration: none; }
.sidebar nav a.active { .sidebar nav a.active {
@@ -86,17 +152,47 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
} }
.sidebar nav a.active .count { background: var(--accent); color: #002923; } .sidebar nav a.active .count { background: var(--accent); color: #002923; }
.sidebar .footer { .sidebar .footer {
padding: 10px 18px; border-top: 1px solid var(--border); padding: 12px 18px; border-top: 1px solid var(--border);
color: var(--fg-dimmer); font-size: 11px; color: var(--fg-dimmer); font-size: 11px;
display: flex; flex-direction: column; gap: 4px;
}
.sidebar .footer code { background: transparent; color: var(--fg-dim); padding: 0;
font-size: 11px; word-break: break-all; }
.sidebar .footer .status-row {
display: flex; align-items: center; gap: 8px;
margin-bottom: 4px;
}
.sidebar .footer .status-row .dot {
width: 8px; height: 8px; border-radius: 50%; display: inline-block;
background: var(--fg-dimmer); flex: none;
}
.sidebar .footer .status-row .dot.ok { background: var(--ok);
box-shadow: 0 0 6px color-mix(in srgb, var(--ok) 60%, transparent); }
.sidebar .footer .status-row .dot.err { background: var(--err); }
.sidebar .footer .status-row .dot.warn { background: var(--warn); }
.sidebar .footer .status-label { color: var(--fg-dim); }
.sidebar .footer .status-value { color: var(--fg); font-weight: 600; }
.sidebar .footer .status-value.ok { color: var(--ok); }
.sidebar .footer .status-value.err { color: var(--err); }
.sidebar .footer .status-value.warn { color: var(--warn); }
.sidebar .footer .footer-sub { color: var(--fg-dimmer); margin-top: 2px; }
/* Persistent backend identity. Sits below the advertised URL so even
when an operator has uploaded their own logo, "what is this" stays
answerable from the bottom-left of every page. */
.sidebar .footer .footer-version {
margin-top: 8px; padding-top: 8px;
border-top: 1px dashed var(--border-soft);
color: var(--fg-dim);
font-variant-numeric: tabular-nums;
letter-spacing: 0.2px;
} }
.sidebar .footer code { background: transparent; color: var(--fg-dim); padding: 0; }
/* ── Top bar ───────────────────────────────────────────────────────── */ /* ── Top bar ───────────────────────────────────────────────────────── */
.topbar { .topbar {
grid-area: topbar; grid-area: topbar;
display: flex; align-items: center; display: flex; align-items: center;
padding: 0 20px; gap: 18px; padding: 0 20px; gap: 14px;
background: var(--bg-panel); background: var(--bg-panel);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
@@ -104,23 +200,31 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
margin: 0; font-size: 15px; font-weight: 600; margin: 0; font-size: 15px; font-weight: 600;
color: var(--fg); letter-spacing: 0.2px; color: var(--fg); letter-spacing: 0.2px;
} }
.topbar .tabs { display: flex; gap: 4px; margin-left: 24px; }
.topbar .tabs button {
background: transparent; border: 0;
color: var(--fg-dim); font: inherit;
padding: 10px 14px; cursor: pointer;
border-bottom: 2px solid transparent;
}
.topbar .tabs button:hover { color: var(--fg); }
.topbar .tabs button.active { color: var(--accent); border-bottom-color: var(--accent); }
.topbar .spacer { flex: 1; } .topbar .spacer { flex: 1; }
.topbar .chip { .topbar .chip {
background: var(--bg-panel-2); border: 1px solid var(--border); background: var(--bg-panel-2); border: 1px solid var(--border);
color: var(--fg-dim); font-size: 12px; color: var(--fg-dim); font-size: 12px;
padding: 4px 10px; border-radius: 12px; padding: 4px 10px; border-radius: 12px;
white-space: nowrap;
} }
.topbar .chip strong { color: var(--fg); font-weight: 600; } .topbar .chip strong { color: var(--fg); font-weight: 600; }
/* Theme toggle button. Two glyphs stacked; CSS swaps which is visible
based on the active theme. Keeps the layout stable when toggling. */
.theme-toggle {
display: inline-flex; align-items: center; justify-content: center;
width: 36px; height: 32px;
background: transparent; color: var(--fg);
border: 1px solid var(--border); border-radius: 8px;
cursor: pointer; padding: 0;
transition: background 0.15s ease, border-color 0.15s ease;
}
.theme-toggle:hover { background: var(--bg-panel-2); border-color: var(--accent); }
.theme-toggle .t-sun { display: none; }
.theme-toggle .t-moon { display: inline; }
:root[data-theme="light"] .theme-toggle .t-sun { display: inline; }
:root[data-theme="light"] .theme-toggle .t-moon { display: none; }
/* ── Main content ─────────────────────────────────────────────────── */ /* ── Main content ─────────────────────────────────────────────────── */
.main { .main {
@@ -142,6 +246,7 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
overflow: hidden; overflow: hidden;
box-shadow: var(--shadow-card);
} }
.card > header { .card > header {
padding: 12px 16px; padding: 12px 16px;
@@ -153,9 +258,7 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
.card > header .sub { color: var(--fg-dim); font-size: 12px; margin-left: auto; } .card > header .sub { color: var(--fg-dim); font-size: 12px; margin-left: auto; }
.card .body { padding: 16px; } .card .body { padding: 16px; }
.stat { .stat { padding: 16px; }
padding: 16px;
}
.stat .label { color: var(--fg-dim); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.8px; } .stat .label { color: var(--fg-dim); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.8px; }
.stat .value { font-size: 28px; font-weight: 600; line-height: 1.1; margin-top: 4px; color: var(--fg); } .stat .value { font-size: 28px; font-weight: 600; line-height: 1.1; margin-top: 4px; color: var(--fg); }
.stat .trend { font-size: 12px; color: var(--fg-dim); margin-top: 4px; } .stat .trend { font-size: 12px; color: var(--fg-dim); margin-top: 4px; }
@@ -179,12 +282,12 @@ td.num { text-align: right; font-variant-numeric: tabular-nums; }
display: inline-block; display: inline-block;
padding: 2px 8px; border-radius: 10px; padding: 2px 8px; border-radius: 10px;
font-size: 11px; font-weight: 600; font-size: 11px; font-weight: 600;
background: #1b3148; color: #a2c5e8; background: var(--bg-elev); color: var(--fg-dim);
} }
.tag.ok { background: #103428; color: var(--ok); } .tag.ok { background: color-mix(in srgb, var(--ok) 22%, transparent); color: var(--ok); }
.tag.warn { background: #3a2a10; color: var(--warn); } .tag.warn { background: color-mix(in srgb, var(--warn) 22%, transparent); color: var(--warn); }
.tag.err { background: #3a1515; color: var(--err); } .tag.err { background: color-mix(in srgb, var(--err) 22%, transparent); color: var(--err); }
.tag.accent { background: #072f29; color: var(--accent); } .tag.accent { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--accent); }
.tag.arch { text-transform: uppercase; } .tag.arch { text-transform: uppercase; }
/* ── Forms ────────────────────────────────────────────────────────── */ /* ── Forms ────────────────────────────────────────────────────────── */
@@ -194,35 +297,96 @@ button, .btn {
border: 0; border-radius: var(--radius); border: 0; border-radius: var(--radius);
padding: 7px 14px; font: inherit; font-weight: 600; padding: 7px 14px; font: inherit; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background 0.12s ease;
} }
button:hover, .btn:hover { background: var(--accent-dim); color: #fff; } button:hover, .btn:hover { background: var(--accent-dim); color: #f4fffd; }
button.ghost { background: transparent; color: var(--fg); border: 1px solid var(--border); } button.ghost { background: transparent; color: var(--fg); border: 1px solid var(--border); }
button.ghost:hover { background: var(--bg-panel-2); color: var(--fg); } button.ghost:hover { background: var(--bg-panel-2); color: var(--fg); }
button.danger { background: transparent; color: var(--err); border: 1px solid #4a1f1f; } button.danger { background: transparent; color: var(--err); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); }
button.danger:hover { background: #2a0b0b; color: var(--err); } button.danger:hover { background: color-mix(in srgb, var(--err) 14%, transparent); color: var(--err); }
label.field { label.field {
display: grid; gap: 4px; margin-bottom: 14px; display: grid; gap: 4px; margin-bottom: 14px;
} }
label.field .name { color: var(--fg-dim); font-size: 12px; } label.field .name { color: var(--fg-dim); font-size: 12px; }
label.field .hint { color: var(--fg-dimmer); font-size: 11px; } label.field .hint { color: var(--fg-dimmer); font-size: 11px; }
label.field input[type="text"], /* All single-line inputs share one chrome rule. Pre-v0.4.6 we only
label.field input[type="number"], styled type=text/number, which left type=password fields rendering
with the default browser look visibly off vs adjacent text fields
in the Account card. The negation list keeps `type=checkbox`,
`type=file`, and `type=range` (none of which we use inside
`label.field`) from picking up the padded-box look. */
label.field input:not([type="checkbox"]):not([type="file"]):not([type="range"]),
label.field select, label.field select,
label.field textarea { label.field textarea {
width: 100%; background: var(--bg); color: var(--fg); width: 100%; background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: var(--radius); border: 1px solid var(--border); border-radius: var(--radius);
padding: 7px 10px; font: inherit; padding: 7px 10px; font: inherit;
/* iOS/Safari shrinks password-field text by default; clamp it so
the password input matches the username input's metrics. */
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 { label.field input:focus, label.field select:focus, label.field textarea:focus {
outline: none; border-color: var(--accent); outline: none; border-color: var(--accent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
} }
label.check { label.check {
display: flex; gap: 10px; align-items: center; display: flex; gap: 10px; align-items: center;
padding: 8px 10px; margin-bottom: 6px; padding: 8px 10px; margin-bottom: 6px;
border: 1px solid var(--border-soft); border-radius: var(--radius); border: 1px solid var(--border-soft); border-radius: var(--radius);
} }
label.check input { accent-color: var(--accent); } /* v0.4.63: native checkboxes used to render as opaque black squares in
light mode because the page meta declares `color-scheme: dark light`
and `accent-color` alone only repaints the *check mark* (not the
container). Take full control of the chrome so the box reads cleanly
on both palettes and the checked state lights up in our accent. */
label.check input[type="checkbox"] {
appearance: none; -webkit-appearance: none;
width: 16px; height: 16px; flex: none;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
display: inline-grid; place-content: center;
cursor: pointer; margin: 0;
transition: background 0.1s ease, border-color 0.1s ease;
}
label.check input[type="checkbox"]:hover { border-color: var(--accent); }
label.check input[type="checkbox"]:checked {
background: var(--accent);
border-color: var(--accent);
}
label.check input[type="checkbox"]:checked::after {
/* Classic glyph built from a rotated rectangle border. Colour is
#002923 (the same near-black we use on solid-accent buttons) so the
tick stays legible against the teal fill in both themes. */
content: '';
width: 4px; height: 8px;
border: solid #002923;
border-width: 0 2px 2px 0;
transform: rotate(45deg) translate(-1px, -1px);
}
label.check input[type="checkbox"]:focus-visible {
outline: none;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
}
/* ── Drop zone ────────────────────────────────────────────────────── */ /* ── Drop zone ────────────────────────────────────────────────────── */
@@ -238,38 +402,71 @@ label.check input { accent-color: var(--accent); }
background: var(--bg-panel-2); background: var(--bg-panel-2);
} }
.drop strong { color: var(--accent); } .drop strong { color: var(--accent); }
/* Plain progress bar (used for ISO uploads). */
.progress { height: 6px; background: var(--bg-panel-2); border-radius: 3px; overflow: hidden; margin-top: 12px; display: none; } .progress { height: 6px; background: var(--bg-panel-2); border-radius: 3px; overflow: hidden; margin-top: 12px; display: none; }
.progress.active { display: block; } .progress.active { display: block; }
.progress .bar { height: 100%; width: 0%; background: var(--accent); transition: width .25s; } .progress .bar { height: 100%; width: 0%; background: var(--accent); transition: width .25s; }
/* ── Gate queue "horse race" visual ───────────────────────────────── */ /* Imaging progress widget
Animated brand mark paired with a horizontal progress bar; surfaces
.gate-track { on Dashboard and the Queue tab. */
display: grid; gap: 6px; .queue-progress {
padding: 10px 0; display: flex; align-items: center; gap: 16px;
padding: 16px;
} }
.gate-row { .queue-progress .mark {
display: grid; grid-template-columns: 32px 1fr auto auto; align-items: center; width: 56px; height: 56px; flex: none; border-radius: 50%;
gap: 14px; background: url("/assets/loader.svg") no-repeat center / contain;
padding: 8px 14px; filter: drop-shadow(0 0 16px rgba(255, 255, 255, 0.10));
background: var(--bg-panel-2); border-radius: var(--radius);
border-left: 3px solid var(--accent);
} }
.gate-row.assigned { border-left-color: var(--ok); } .queue-progress .info { flex: 1; min-width: 0; }
.gate-row .pos { font-family: var(--mono); font-size: 15px; color: var(--accent); font-weight: 600; } .queue-progress .info .label {
.gate-row.assigned .pos { color: var(--ok); } font-size: 12.5px; color: var(--fg-dim); margin-bottom: 6px;
.gate-row .mac { font-family: var(--mono); font-size: 13px; } }
.gate-row .meta { color: var(--fg-dim); font-size: 12px; } .queue-progress .bar-track {
height: 8px; background: var(--bg-elev); border-radius: 4px;
overflow: hidden; position: relative;
}
.queue-progress .bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-dim), var(--accent));
width: 0%;
transition: width 0.4s ease;
position: relative;
}
.queue-progress .bar-fill::after {
/* Subtle moving sheen so the bar feels alive even at 0% movement. */
content: ""; position: absolute; inset: 0;
background: linear-gradient(
90deg,
rgba(255,255,255,0) 0%,
rgba(255,255,255,0.18) 50%,
rgba(255,255,255,0) 100%);
animation: queue-sheen 1.6s linear infinite;
}
@keyframes queue-sheen {
from { transform: translateX(-100%); }
to { transform: translateX(100%); }
}
.queue-progress.idle .mark { filter: grayscale(0.85) opacity(0.55); }
.queue-progress.idle .bar-fill::after { animation: none; }
.empty { color: var(--fg-dim); padding: 30px; text-align: center; } /* ── Page-load loader ────────────────────────────────────────────── */
.msg { color: var(--fg-dim); font-size: 12.5px; margin-top: 8px; } .loader {
.msg.err { color: var(--err); } display: flex; flex-direction: column; align-items: center; gap: 12px;
.msg.ok { color: var(--ok); } padding: 40px 20px;
color: var(--fg-dim);
}
.loader .mark {
width: 96px; height: 96px;
background: url("/assets/loader.svg") no-repeat center / contain;
}
/* ── Top bar readiness chip ──────────────────────────────────────── */ /* ── Top bar readiness chip ──────────────────────────────────────── */
.chip.ready { background: #103428; color: var(--ok); border-color: #1a4f3c; } .chip.ready { background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok); border-color: color-mix(in srgb, var(--ok) 35%, transparent); }
.chip.notready { background: #3a1515; color: var(--err); border-color: #5a1f1f; } .chip.notready { background: color-mix(in srgb, var(--err) 18%, transparent); color: var(--err); border-color: color-mix(in srgb, var(--err) 35%, transparent); }
.chip.warming { background: #3a2a10; color: var(--warn); border-color: #4a3a18; } .chip.warming { background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); border-color: color-mix(in srgb, var(--warn) 35%, transparent); }
/* ── Dashboard stat strip ────────────────────────────────────────── */ /* ── Dashboard stat strip ────────────────────────────────────────── */
.statstrip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; } .statstrip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
@@ -283,17 +480,18 @@ label.check input { accent-color: var(--accent); }
.kv .v.err { color: var(--err); } .kv .v.err { color: var(--err); }
.kv .v.ok { color: var(--ok); } .kv .v.ok { color: var(--ok); }
/* ── Image rows: amber tint on un-bootable images (Bootimus pattern) ── */ /* ── Image rows: amber tint on un-bootable images ────────────────── */
tr.unbootable td { background: rgba(255, 179, 71, 0.07) !important; } tr.unbootable td { background: color-mix(in srgb, var(--warn) 7%, transparent) !important; }
tr.unbootable td:first-child { border-left: 3px solid var(--warn); } tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
.row-warn { color: var(--warn); font-size: 11.5px; margin-top: 2px; } .row-warn { color: var(--warn); font-size: 11.5px; margin-top: 2px; }
/* ── Table source badge ──────────────────────────────────────────── */ /* ── Table source badge ──────────────────────────────────────────── */
.src-badge { font-family: var(--mono); font-size: 11px; padding: 1px 6px; .src-badge { font-family: var(--mono); font-size: 11px; padding: 1px 6px;
border-radius: 4px; background: var(--bg-elev); color: var(--fg-dim); } border-radius: 4px; background: var(--bg-elev); color: var(--fg-dim); }
.src-badge.nfs { background: #122a3a; color: #7cd3ff; } .src-badge.nfs { background: color-mix(in srgb, #7cd3ff 18%, var(--bg-elev));
color: color-mix(in srgb, #7cd3ff 90%, var(--fg)); }
/* ── NFS modal-ish add form ──────────────────────────────────────── */ /* ── NFS rows ────────────────────────────────────────────────────── */
.nfs-row { display: grid; grid-template-columns: 32px 1fr auto auto auto; align-items: center; .nfs-row { display: grid; grid-template-columns: 32px 1fr auto auto auto; align-items: center;
gap: 14px; padding: 10px 14px; background: var(--bg-panel-2); gap: 14px; padding: 10px 14px; background: var(--bg-panel-2);
border-left: 3px solid var(--accent); border-radius: var(--radius); } border-left: 3px solid var(--accent); border-radius: var(--radius); }
@@ -306,39 +504,78 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
.dot.err { background: var(--err); } .dot.err { background: var(--err); }
.dot.warn { background: var(--warn); } .dot.warn { background: var(--warn); }
/* ── Inline form rows (used by Network + NFS add) ───────────────── */ /* Inline form rows. The default is a 4-column grid sized for the
.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; } Account card's "Current / New username / New password / Confirm"
@media (max-width: 900px) { .form-row { grid-template-columns: 1fr; } } quartet; the `.cols-3` modifier swaps to a 3-column layout for the
SSO header strip (display name / logo URL / metadata source). All
`.form-row > label.field` children share the same baseline because
their inner inputs share metrics via the global rule above. */
.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; align-items: end; }
.form-row.cols-3 { grid-template-columns: repeat(3, 1fr); }
.form-row.cols-2 { grid-template-columns: repeat(2, 1fr); }
.form-row label.field { margin-bottom: 0; }
@media (max-width: 900px) {
.form-row,
.form-row.cols-3,
.form-row.cols-2 { grid-template-columns: 1fr; }
}
/* ── Queued deployment visual ────────────────────────────────────── */
.queue-track {
display: grid; gap: 6px;
padding: 10px 0;
}
.queue-row {
display: grid; grid-template-columns: 32px 1fr auto auto; align-items: center;
gap: 14px;
padding: 8px 14px;
background: var(--bg-panel-2); border-radius: var(--radius);
border-left: 3px solid var(--accent);
}
.queue-row.assigned { border-left-color: var(--ok); }
.queue-row .pos { font-family: var(--mono); font-size: 15px; color: var(--accent); font-weight: 600; }
.queue-row.assigned .pos { color: var(--ok); }
.queue-row .mac { font-family: var(--mono); font-size: 13px; }
.queue-row .meta { color: var(--fg-dim); font-size: 12px; }
.empty { color: var(--fg-dim); padding: 30px; text-align: center; }
.msg { color: var(--fg-dim); font-size: 12.5px; margin-top: 8px; }
.msg.err { color: var(--err); }
.msg.ok { color: var(--ok); }
/* ── Terminal pane ──────────────────────────────────────────────── */ /* ── Terminal pane ──────────────────────────────────────────────── */
.terminal { .terminal {
display: flex; flex-direction: column; display: flex; flex-direction: column;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: #06090e; background: var(--terminal-bg);
overflow: hidden; overflow: hidden;
height: calc(100vh - var(--topbar-h) - 90px); height: calc(100vh - var(--topbar-h) - 90px);
min-height: 480px; min-height: 480px;
box-shadow: var(--shadow-card);
} }
/* Terminal pane colours follow the active theme. Hard-coded hexes
(#050505, #181818, #cfd6e2 etc.) were leaving the light-mode pane
looking dark; we keep palette-aware vars instead so the toggle works. */
.terminal .pane { .terminal .pane {
flex: 1; overflow: auto; flex: 1; overflow: auto;
padding: 10px 14px; padding: 10px 14px;
font-family: var(--mono); font-size: 12.5px; line-height: 1.5; font-family: var(--mono); font-size: 12.5px; line-height: 1.5;
color: #cfd6e2; color: var(--fg);
white-space: pre-wrap; word-break: break-word; white-space: pre-wrap; word-break: break-word;
} }
.terminal .pane .lvl-error { color: var(--err); } .terminal .pane .lvl-error { color: var(--err); }
.terminal .pane .lvl-warn { color: var(--warn); } .terminal .pane .lvl-warn { color: var(--warn); }
.terminal .pane .lvl-info { color: #cfd6e2; } .terminal .pane .lvl-info { color: var(--fg); }
.terminal .pane .lvl-debug { color: var(--fg-dim); } .terminal .pane .lvl-debug { color: var(--fg-dim); }
.terminal .pane .lvl-trace { color: var(--fg-dimmer); } .terminal .pane .lvl-trace { color: var(--fg-dimmer); }
.terminal .pane .ts { color: var(--fg-dimmer); } .terminal .pane .ts { color: var(--fg-dimmer); }
.terminal .pane .tg { color: #7cd3ff; } .terminal .pane .tg { color: var(--accent); }
.terminal .pane .echo { color: var(--accent); } .terminal .pane .echo { color: var(--accent); }
.terminal .input-row { .terminal .input-row {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
padding: 8px 14px; padding: 8px 14px;
background: #0a0e15; background: var(--bg-panel-2);
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
.terminal .input-row .prompt { color: var(--accent); font-family: var(--mono); } .terminal .input-row .prompt { color: var(--accent); font-family: var(--mono); }
@@ -362,10 +599,308 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
} }
.terminal .toolbar button:hover { color: var(--fg); background: var(--bg-elev); } .terminal .toolbar button:hover { color: var(--fg); background: var(--bg-elev); }
/* Auth screen (first-run setup + login)
Used when /api/me reports setup_required or !authenticated. The
regular .shell is hidden; this overlay takes the full viewport so
the operator never sees half-loaded dashboard chrome while the auth
state is unknown. Same palette as the rest of the UI borrows the
Sonarr/Radarr layout (centered narrow card on the page background).
*/
.auth-screen {
position: fixed; inset: 0;
display: flex; align-items: center; justify-content: center;
background: var(--bg);
padding: 24px;
z-index: 100;
}
.auth-card {
width: 100%; max-width: 380px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 28px 28px 22px;
}
.auth-card .brand-row {
display: flex; align-items: center; gap: 12px;
margin-bottom: 18px;
}
.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);
}
.auth-card .lede {
color: var(--fg-dim); font-size: 13px; margin: 0 0 18px;
line-height: 1.5;
}
.auth-card .field { margin-bottom: 12px; }
.auth-card input[type="text"],
.auth-card input[type="password"] {
width: 100%; background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 9px 11px; font: inherit; font-size: 13.5px;
}
.auth-card input:focus { outline: none; border-color: var(--accent); }
.auth-card .submit { width: 100%; padding: 9px 12px; margin-top: 6px; }
.auth-card .auth-err {
margin-top: 12px; color: var(--err); font-size: 12.5px;
}
.auth-card .auth-foot {
margin-top: 14px; padding-top: 12px;
border-top: 1px solid var(--border-soft);
color: var(--fg-dimmer); font-size: 11.5px; text-align: center;
}
.auth-card .sso-btn {
width: 100%; margin-top: 10px;
background: transparent; color: var(--fg);
border: 1px solid var(--border);
padding: 9px 12px;
}
.auth-card .sso-btn:hover {
background: var(--bg-panel-2); border-color: var(--accent); color: var(--fg);
}
.auth-card .sso-btn .meta { color: var(--fg-dim); font-size: 11px; margin-top: 2px; }
/* Top-right user menu (v0.4.6)
The "signed in as X" identity + sign-out moved out of the sidebar
footer in v0.4.6 the sidebar footer is now reserved for the
service-state trio (Service status / Advertised URL / Backend
version). The button matches the theme toggle's size + chrome so
the top-right reads as a tidy two-icon strip. */
.user-menu { position: relative; }
.user-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 36px; height: 32px;
background: transparent; color: var(--fg);
border: 1px solid var(--border); border-radius: 8px;
cursor: pointer; padding: 0;
transition: background 0.15s ease, border-color 0.15s ease;
}
.user-btn:hover { background: var(--bg-panel-2); border-color: var(--accent); }
.user-pop {
position: absolute; right: 0; top: 38px;
min-width: 200px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 6px;
z-index: 60;
display: flex; flex-direction: column; gap: 2px;
}
.user-pop[hidden] { display: none; }
.user-pop .user-pop-name {
padding: 8px 10px 6px;
border-bottom: 1px solid var(--border-soft);
margin-bottom: 4px;
color: var(--fg); font-weight: 600; font-size: 13px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.user-pop .user-pop-item {
text-align: left; width: 100%;
background: transparent; color: var(--fg);
border: 0; border-radius: var(--radius);
padding: 7px 10px; font: inherit; font-size: 13px; font-weight: 500;
cursor: pointer;
}
.user-pop .user-pop-item:hover {
background: var(--bg-panel-2); color: var(--fg);
}
.user-pop .user-pop-danger { color: var(--err); }
.user-pop .user-pop-danger:hover {
background: color-mix(in srgb, var(--err) 12%, transparent);
color: var(--err);
}
/* ── About card ─────────────────────────────────────────────────── */ /* ── About card ─────────────────────────────────────────────────── */
.about-hero { padding: 20px 24px; } .about-hero { padding: 20px 24px; }
.about-hero h2 { font-size: 22px; margin: 0 0 8px; color: var(--fg); } .about-hero h2 { font-size: 22px; margin: 0 0 8px; color: var(--fg); }
.about-hero .lead { color: var(--fg-dim); font-size: 14px; max-width: 60ch; } /* Span the full main column rather than capping at 60ch the page is
read at typical desktop widths and the cap was leaving the right two
thirds of the panel awkwardly empty. */
.about-hero .lead { color: var(--fg-dim); font-size: 14px; max-width: none; }
.about-hero .who { margin-top: 18px; font-size: 13px; } .about-hero .who { margin-top: 18px; font-size: 13px; }
.about-hero .who span { color: var(--fg-dim); } .about-hero .who span { color: var(--fg-dim); }
.about-hero .who strong { color: var(--accent); } .about-hero .who strong { color: var(--accent); }
.about-hero a { color: var(--accent); }
/* ── API reference (Settings → bottom) ─────────────────────────── */
.api-ref { display: grid; gap: 18px; padding: 16px; }
.api-ref .group h3 {
margin: 0 0 8px; font-size: 13px; color: var(--fg-dim);
text-transform: uppercase; letter-spacing: 0.8px;
}
.api-ref .ep {
display: grid; grid-template-columns: 64px minmax(200px, 1fr) 2fr;
gap: 12px; align-items: baseline;
padding: 6px 0; border-top: 1px solid var(--border-soft);
font-size: 13px;
}
.api-ref .ep:first-child { border-top: 0; }
.api-ref .ep .method {
font-family: var(--mono); font-weight: 600; font-size: 11px;
padding: 2px 6px; border-radius: 4px;
text-align: center; letter-spacing: 0.6px;
}
.api-ref .ep .method.get { background: color-mix(in srgb, var(--ok) 22%, transparent); color: var(--ok); }
.api-ref .ep .method.post { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--accent); }
.api-ref .ep .method.put { background: color-mix(in srgb, var(--warn) 22%, transparent); color: var(--warn); }
.api-ref .ep .method.delete { background: color-mix(in srgb, var(--err) 22%, transparent); color: var(--err); }
.api-ref .ep .path { font-family: var(--mono); color: var(--fg); word-break: break-all; }
.api-ref .ep .desc { color: var(--fg-dim); }
@media (max-width: 900px) {
.api-ref .ep { grid-template-columns: 1fr; gap: 4px; }
.api-ref .ep .method { justify-self: start; }
}
/* ── Disk space card ───────────────────────────────────────────── */
.diskbar {
height: 10px; border-radius: 5px;
background: var(--bg-elev);
overflow: hidden; margin-top: 8px;
}
.diskbar .fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-dim), var(--accent));
transition: width 0.4s ease;
}
.diskbar.warn .fill { background: var(--warn); }
.diskbar.full .fill { background: var(--err); }
.disk-meta { display: flex; gap: 14px; font-size: 12px; color: var(--fg-dim); margin-top: 8px; flex-wrap: wrap; }
.disk-meta strong { color: var(--fg); font-weight: 600; font-variant-numeric: tabular-nums; }
/* ── Logo upload (Settings) ────────────────────────────────────── */
.logo-preview {
display: flex; align-items: center; gap: 14px;
padding: 12px;
background: var(--bg-panel-2);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.logo-preview .swatch {
width: 56px; height: 56px;
display: flex; align-items: center; justify-content: center;
background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius);
flex: none;
}
.logo-preview .swatch img { max-width: 48px; max-height: 48px; }
.logo-preview .info { flex: 1; min-width: 0; }
.logo-preview .info .name { color: var(--fg); font-weight: 600; }
.logo-preview .info .meta { color: var(--fg-dim); font-size: 12px; margin-top: 2px; }
/* v0.5.1: collapsible "Advanced" disclosure at the bottom of Settings
(the former Advanced sidebar tab). A quiet, full-width toggle that
expands to reveal the notification + API-reference cards. */
.advanced-disclosure { width: 100%; }
.advanced-summary {
list-style: none;
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
color: var(--fg-dim);
font-size: 13px;
font-weight: 600;
background: var(--bg-panel-2);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.advanced-summary:hover { color: var(--fg); }
.advanced-summary::-webkit-details-marker { display: none; }
.advanced-summary::before {
content: "▸";
font-size: 11px;
transition: transform 0.15s ease;
}
.advanced-disclosure[open] .advanced-summary::before { transform: rotate(90deg); }
/* v0.5.1: protocol tag on a unified Remote-shares row (SMB / NFS). */
.proto-badge {
display: inline-block;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
padding: 1px 6px;
margin-right: 8px;
border-radius: 4px;
vertical-align: middle;
background: var(--bg-panel-2);
border: 1px solid var(--border);
color: var(--fg-dim);
}
/* ── v0.5.2: three-slot branding (light / dark / client) ─────────── */
.logo-slots {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
@media (max-width: 720px) { .logo-slots { grid-template-columns: 1fr; } }
.logo-slot {
display: flex; flex-direction: column; gap: 8px;
padding: 12px;
background: var(--bg-panel-2);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.logo-slot-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.logo-slot-head .name { color: var(--fg); font-weight: 600; font-size: 13px; }
.logo-slot .swatch {
height: 64px;
display: flex; align-items: center; justify-content: center;
background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius);
}
.logo-slot .swatch img { max-width: 90%; max-height: 52px; object-fit: contain; }
.logo-slot-hint { color: var(--fg-dim); font-size: 11.5px; }
/* ── v0.5.2: login local/SSO separation ─────────────────────────── */
.auth-card .auth-divider {
display: flex; align-items: center; text-align: center;
color: var(--fg-dimmer); font-size: 11px; text-transform: uppercase;
letter-spacing: 0.08em;
margin: 16px 0 12px;
}
.auth-card .auth-divider::before,
.auth-card .auth-divider::after {
content: ""; flex: 1; height: 1px; background: var(--border-soft);
}
.auth-card .auth-divider span { padding: 0 10px; }
.auth-card .sso-block .sso-btn { margin-top: 0; }
.auth-card .sso-btn {
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.auth-card .sso-btn .sso-logo { width: 16px; height: 16px; object-fit: contain; flex: none; }
/* ── v0.5.2: modal (queue Profile editor) ───────────────────────── */
.modal-overlay {
position: fixed; inset: 0; z-index: 200;
display: flex; align-items: center; justify-content: center;
background: rgba(0, 0, 0, 0.55);
padding: 24px;
}
.modal-box {
width: 100%; max-width: 520px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 22px;
}
.modal-box h2 { margin: 0 0 14px; font-size: 16px; font-weight: 600; color: var(--fg); }
.modal-actions {
display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px;
}
.modal-actions .submit { width: auto; padding: 8px 18px; }
+1855 -149
View File
File diff suppressed because it is too large Load Diff
+92 -16
View File
@@ -3,52 +3,128 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PXEForge</title> <meta name="color-scheme" content="dark light" />
<link rel="stylesheet" href="/assets/app.css" /> <title>OpenPXE</title>
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg" /> <!-- v0.4.61: the `?v=…` query string is replaced by the server at
request time with the running OpenPXE version. That guarantees a
fresh URL on every upgrade so browsers (and intermediary proxies)
can't keep serving stale JS / CSS / branding from before the
deploy. Combined with `Cache-Control: no-cache, must-revalidate`
on the asset handlers, the practical caching window is one
version. -->
<link rel="stylesheet" href="/assets/app.css?v={{ASSET_VERSION}}" />
<!-- v0.5.2: favicon is pinned to the bundled OpenPXE mark (its own
endpoint, decoupled from operator branding) for tab-icon
continuity regardless of any uploaded light/dark/client logo. -->
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg?v={{ASSET_VERSION}}" />
<!-- Theme is read from localStorage *before* paint to avoid the
dark→light flash on every navigation. Falls back to the OS
preference and finally to dark. -->
<script>
(function() {
try {
var stored = localStorage.getItem('openpxe-theme');
var theme = stored || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
</head> </head>
<body> <body>
<div class="shell"> <div class="shell">
<aside class="sidebar"> <aside class="sidebar">
<div class="brand"> <div class="{{BRAND_CLASS}}">
<img src="/assets/logo.svg" alt="" /> <img src="/assets/logo.svg?v={{ASSET_VERSION}}&r={{LOGO_REV}}" alt="OpenPXE" />
<div> <strong>OpenPXE</strong>
<strong>PXEForge</strong>
<div class="sub">v<span data-bind="version">0.1.0</span></div>
</div>
</div> </div>
<nav> <nav>
<a data-view="dashboard" class="active">Dashboard</a> <a data-view="dashboard" class="active">Dashboard</a>
<a data-view="network">Network</a> <a data-view="network">Network</a>
<a data-view="gate"> <a data-view="queue">
Forge Gate Queue
<span class="count" data-bind="gate_count">0</span> <span class="count" data-bind="queue_count">0</span>
</a> </a>
<a data-view="storage"> <a data-view="storage">
Storage Storage
<span class="count" data-bind="iso_count">0</span> <span class="count" data-bind="iso_count">0</span>
</a> </a>
<a data-view="hosts">
Hosts
<span class="count" data-bind="host_count">0</span>
</a>
<a data-view="terminal">Terminal</a> <a data-view="terminal">Terminal</a>
<a data-view="settings">Settings</a>
<a data-view="about">About</a> <a data-view="about">About</a>
</nav> </nav>
<div class="footer"> <div class="footer">
Advertised to clients<br/> <div class="status-row">
<span class="dot" data-bind="ready_dot" title="Server readiness"></span>
<span class="status-label">Service status:</span>
<span class="status-value" data-bind="ready_label">checking…</span>
</div>
<div class="footer-sub">Advertised to clients</div>
<code>{{BASE_URL}}</code> <code>{{BASE_URL}}</code>
<!-- 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.63</span></div>
</div> </div>
</aside> </aside>
<header class="topbar"> <header class="topbar">
<h1 data-bind="view_title">Dashboard</h1> <h1 data-bind="view_title">Dashboard</h1>
<div class="spacer"></div> <div class="spacer"></div>
<span class="chip" data-bind="ready_chip" title="Server readiness">checking…</span>
<span class="chip"><strong data-bind="iso_count2">0</strong>&nbsp;images</span> <span class="chip"><strong data-bind="iso_count2">0</strong>&nbsp;images</span>
<span class="chip"><strong data-bind="client_count2">0</strong>&nbsp;clients</span> <span class="chip"><strong data-bind="client_count2">0</strong>&nbsp;clients</span>
<span class="chip"><strong data-bind="gate_count2">0</strong>&nbsp;at gate</span> <span class="chip"><strong data-bind="queue_count2">0</strong>&nbsp;in queue</span>
<button id="theme-toggle" class="theme-toggle" type="button"
aria-label="Toggle light/dark theme" title="Toggle theme (T)">
<!-- Two glyphs; CSS shows whichever matches the active theme. -->
<svg class="t-sun" viewBox="0 0 24 24" width="18" height="18" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="12" cy="12" r="4.2"/>
<line x1="12" y1="2.5" x2="12" y2="5.5"/>
<line x1="12" y1="18.5" x2="12" y2="21.5"/>
<line x1="2.5" y1="12" x2="5.5" y2="12"/>
<line x1="18.5" y1="12" x2="21.5" y2="12"/>
<line x1="5.2" y1="5.2" x2="7.3" y2="7.3"/>
<line x1="16.7" y1="16.7" x2="18.8" y2="18.8"/>
<line x1="5.2" y1="18.8" x2="7.3" y2="16.7"/>
<line x1="16.7" y1="7.3" x2="18.8" y2="5.2"/>
</svg>
<svg class="t-moon" viewBox="0 0 24 24" width="18" height="18" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20.5 14A8 8 0 0 1 10 3.5 a8 8 0 1 0 10.5 10.5z"/>
</svg>
</button>
<!-- v0.4.6: signed-in operator menu. Sits next to the theme toggle
in the top-right corner so the sidebar footer stays clean for
the "Service status / Advertised URL / Backend version" trio.
The whole block is hidden until /api/me confirms a session. -->
<div class="user-menu" data-bind="user_menu_wrap" style="display:none">
<button id="user-menu-btn" class="user-btn" type="button"
aria-label="Account menu" aria-haspopup="true" aria-expanded="false"
title="Account">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="8" r="3.6"/>
<path d="M4.5 20a7.5 7.5 0 0 1 15 0"/>
</svg>
</button>
<div id="user-menu-pop" class="user-pop" data-bind="user_menu_pop" hidden>
<div class="user-pop-name" data-bind="user_pop_name"></div>
<button type="button" class="user-pop-item" data-bind="user_pop_edit">Edit account</button>
<button type="button" class="user-pop-item user-pop-danger" data-bind="user_pop_logout">Sign out</button>
</div>
</div>
</header> </header>
<main class="main" id="view-root"></main> <main class="main" id="view-root"></main>
</div> </div>
<script src="/assets/app.js"></script> <script src="/assets/app.js?v={{ASSET_VERSION}}"></script>
</body> </body>
</html> </html>
+56 -8
View File
@@ -1,29 +1,77 @@
//! Offline-only web UI. Everything the browser needs (HTML, CSS, JS, SVG //! Offline-only web UI. Everything the browser needs (HTML, CSS, JS, SVG
//! logo) is embedded in the compiled binary via `include_str!` / //! logo) is embedded in the compiled binary via `include_str!` /
//! `include_bytes!`. No CDN, no external fonts, no remote images — //! `include_bytes!`. No CDN, no external fonts, no remote images —
//! PXEForge renders identically on an air-gapped network. //! OpenPXE renders identically on an air-gapped network.
//! //!
//! Layout follows the Netbox Labs pattern: dark left sidebar with primary //! Layout follows the Netbox Labs pattern: dark left sidebar with primary
//! nav, top bar with secondary tabs, card-dense content panels. //! nav, top bar with secondary tabs, card-dense content panels.
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
/// Render the top-level page. `base_url` is interpolated into the footer /// Render the top-level page.
/// so operators can see at a glance what URL clients are PXE-booting from. ///
/// * `base_url` is interpolated into the footer so operators can see at
/// a glance what URL clients are PXE-booting from.
/// * `asset_version` is appended as `?v=…` to every asset URL so each
/// release ships with brand-new asset URLs — browsers (and any
/// intermediary proxy) can't keep serving last release's `app.js`
/// 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] #[must_use]
pub fn index_html(base_url: &str) -> String { pub fn index_html(
INDEX_HTML.replace("{{BASE_URL}}", base_url) 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] #[must_use]
pub fn app_js() -> &'static str { APP_JS } pub fn app_js() -> &'static str {
APP_JS
}
#[must_use] #[must_use]
pub fn app_css() -> &'static str { APP_CSS } pub fn app_css() -> &'static str {
APP_CSS
}
#[must_use] #[must_use]
pub fn logo_svg() -> &'static str { LOGO_SVG } pub fn logo_svg() -> &'static str {
LOGO_SVG
}
/// Larger, faster-cycling rainbow disc — used for the page-load
/// transition and the imaging-progress widget on Dashboard / Queue.
/// Pure SVG + SMIL, no JS, no GIF.
#[must_use]
pub fn loader_svg() -> &'static str {
LOADER_SVG
}
const INDEX_HTML: &str = include_str!("index.html"); const INDEX_HTML: &str = include_str!("index.html");
const APP_CSS: &str = include_str!("app.css"); const APP_CSS: &str = include_str!("app.css");
const APP_JS: &str = include_str!("app.js"); const APP_JS: &str = include_str!("app.js");
const LOGO_SVG: &str = include_str!("logo.svg"); const LOGO_SVG: &str = include_str!("logo.svg");
const LOADER_SVG: &str = include_str!("loader.svg");
+36
View File
@@ -0,0 +1,36 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<title>OpenPXE — loading</title>
<!-- Larger, bolder version of the brand mark for "I'm working" states:
page transitions, the imaging-progress widget on Dashboard / Queue.
The gradient slide is faster (3s) and we add a subtle scale pulse
so the disc looks alive even when paired with a static progress bar.
White inner glow keeps the colours legible against the panel bg. -->
<defs>
<linearGradient id="opxRainbowLg" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#330f1f"/>
<stop offset="12.56%" stop-color="#c83228"/>
<stop offset="25.06%" stop-color="#fb8841"/>
<stop offset="37.56%" stop-color="#d3dd92"/>
<stop offset="50.06%" stop-color="#59824f"/>
<stop offset="62.06%" stop-color="#002414"/>
<stop offset="74.06%" stop-color="#00143d"/>
<stop offset="86.06%" stop-color="#2874d7"/>
<stop offset="100%" stop-color="#99c2ff"/>
<animate attributeName="x1" values="0;-1;0" dur="3s" repeatCount="indefinite"/>
<animate attributeName="x2" values="1;0;1" dur="3s" repeatCount="indefinite"/>
</linearGradient>
<radialGradient id="opxGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="rgba(255,255,255,0.6)"/>
<stop offset="60%" stop-color="rgba(255,255,255,0.05)"/>
<stop offset="100%" stop-color="rgba(255,255,255,0)"/>
</radialGradient>
</defs>
<g>
<circle cx="32" cy="32" r="26" fill="url(#opxRainbowLg)"
stroke="rgba(0,0,0,0.2)" stroke-width="1.2">
<animate attributeName="r" values="25;27;25" dur="2.4s" repeatCount="indefinite"/>
</circle>
<!-- Inner highlight to give the disc a hint of dimensionality. -->
<circle cx="28" cy="26" r="14" fill="url(#opxGlow)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+24 -13
View File
@@ -1,14 +1,25 @@
<svg viewBox="0 0 96 64" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>PXEForge</title> <title>OpenPXE</title>
<!-- Anvil body --> <!-- Brand mark to match openpxe.com: a circular medallion filled with
<path d="M6 22 H82 L70 36 H46 V44 H58 V50 H30 V44 H42 V36 H22 Z" fill="#f0823a" stroke="#3a1f08" stroke-width="1.2"/> the "rainbow-horizon" gradient, sliding 200% across to give a slow
<!-- Horn highlight --> hue rotation. Subtle stroke + drop shadow for legibility on either
<path d="M6 22 L20 22 L14 28 L6 28 Z" fill="#ffb066"/> theme. SMIL keeps it self-driving with no JS or CSS dependency. -->
<!-- Stand + base --> <defs>
<rect x="34" y="50" width="20" height="4" fill="#3a1f08"/> <linearGradient id="opxRainbow" x1="0" y1="0" x2="1" y2="0">
<rect x="22" y="54" width="44" height="6" fill="#1c1107"/> <stop offset="0%" stop-color="#330f1f"/>
<!-- Subtle spark --> <stop offset="12.56%" stop-color="#c83228"/>
<circle cx="86" cy="16" r="1.5" fill="#ffd79a"/> <stop offset="25.06%" stop-color="#fb8841"/>
<circle cx="90" cy="22" r="1" fill="#ffd79a"/> <stop offset="37.56%" stop-color="#d3dd92"/>
<circle cx="82" cy="12" r="1" fill="#ffd79a"/> <stop offset="50.06%" stop-color="#59824f"/>
<stop offset="62.06%" stop-color="#002414"/>
<stop offset="74.06%" stop-color="#00143d"/>
<stop offset="86.06%" stop-color="#2874d7"/>
<stop offset="100%" stop-color="#99c2ff"/>
<animate attributeName="x1" values="0;-1;0" dur="12s" repeatCount="indefinite"/>
<animate attributeName="x2" values="1;0;1" dur="12s" repeatCount="indefinite"/>
</linearGradient>
</defs>
<!-- Outer hairline ring softens the edge in light mode; subtle in dark. -->
<circle cx="12" cy="12" r="10.5" fill="url(#opxRainbow)"
stroke="rgba(0,0,0,0.18)" stroke-width="0.6"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 650 B

After

Width:  |  Height:  |  Size: 1.3 KiB

+120 -39
View File
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
# #
# PXEForge — multi-stage build. # OpenPXE — multi-stage build.
# #
# Design: # Design:
# - stage `fetch`: runs scripts/fetch-ipxe.sh to pull official iPXE binaries # - stage `fetch`: runs scripts/fetch-ipxe.sh to pull official iPXE binaries
@@ -8,15 +8,20 @@
# - stage `build`: compiles the workspace with cargo in release mode. # - stage `build`: compiles the workspace with cargo in release mode.
# - stage `runtime`: Debian slim image with setcap for NET_BIND_SERVICE, # - stage `runtime`: Debian slim image with setcap for NET_BIND_SERVICE,
# running as a non-root UID. No shell in PATH for the service user; # running as a non-root UID. No shell in PATH for the service user;
# attacker surface is just the pxeforge binary + libc. # attacker surface is just the openpxe binary + libc.
# #
# Why not distroless? We want setcap support and easy debug (`oc rsh`). # Why not distroless? We want setcap support and easy debug (`oc rsh`).
# Debian slim at ~75 MB + binary ~25 MB is fine for a PXE server that # Debian slim at ~75 MB + binary ~25 MB is fine for a PXE server that
# spends most of its life idle. # spends most of its life idle.
ARG RUST_VERSION=1.82 ARG RUST_VERSION=1.95
########## fetch iPXE binaries ########## ########## fetch iPXE binaries + wimboot ##########
# 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 FROM debian:12-slim AS fetch
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -24,73 +29,149 @@ WORKDIR /src
COPY scripts/fetch-ipxe.sh scripts/fetch-ipxe.sh COPY scripts/fetch-ipxe.sh scripts/fetch-ipxe.sh
RUN mkdir -p assets/ipxe && bash scripts/fetch-ipxe.sh RUN mkdir -p assets/ipxe && bash scripts/fetch-ipxe.sh
########## build pxeforge ########## ########## 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 FROM rust:${RUST_VERSION}-bookworm AS build
WORKDIR /src WORKDIR /src
# v0.4.5: build a fully static musl binary (matches Bootimus v0.1.70's
# move). The resulting `/openpxe` has no glibc dependency at all, which:
# - Lets the runtime stage be any Linux distro (we still ship Debian
# slim for the `samba` / `wimtools` / `nfs-common` shellouts, but a
# scratch/distroless variant becomes a one-line swap).
# - Cuts a class of "GLIBC_2.39 not found" surprises when running on
# older RHEL/Rocky hosts that don't match Debian 12's libc version.
# - Sidesteps cross-compilation snags (the binary is its own world).
#
# x86_64-unknown-linux-musl is fully static by default (no extra
# RUSTFLAGS needed). musl-tools provides the linker.
RUN apt-get update \
&& apt-get install -y --no-install-recommends musl-tools \
&& rm -rf /var/lib/apt/lists/* \
&& rustup target add x86_64-unknown-linux-musl
# Copy the whole workspace in one go. We used to do a two-pass "cache-prime # Copy the whole workspace in one go. We used to do a two-pass "cache-prime
# with stubs, then real build" dance for dep-compile reuse; that turned out # with stubs, then real build" dance for dep-compile reuse; that turned out
# to silently serve stale stub binaries when cargo's fingerprint didn't # to silently serve stale stub binaries when cargo's fingerprint didn't
# notice the source swap. A single build is ~1.5 min longer on cold cache # notice the source swap. A single build is ~1.5 min longer on cold cache
# but guarantees the binary reflects the sources we copied. # but guarantees the binary reflects the sources we copied.
COPY Cargo.toml rust-toolchain.toml ./ # Do not copy rust-toolchain.toml into the image. The local workspace pins
# developer tooling, but inside Docker we intentionally use the Rust version
# selected by the base image. Copying rust-toolchain.toml with
# `channel = "stable"` makes rustup download a second full toolchain during
# `cargo build`, which is slow and can exhaust small Colima/CI disks.
COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/ COPY crates/ crates/
# Baseline binaries (BIOS / i386 / wimboot), then overlay the
# PNG-enabled x86_64 + arm64 UEFI binaries built from source. The
# overlay wins for snponly.efi / ipxe.efi / snponly-arm64.efi so the
# common modern clients get the graphical background; the rest keep the
# upstream no-PNG binaries and the menu's `|| console` text fallback.
COPY --from=fetch /src/assets/ipxe /src/assets/ipxe COPY --from=fetch /src/assets/ipxe /src/assets/ipxe
COPY --from=ipxe-build /src/assets/ipxe/snponly.efi /src/assets/ipxe/snponly.efi
COPY --from=ipxe-build /src/assets/ipxe/ipxe.efi /src/assets/ipxe/ipxe.efi
# Cache cargo registry + target across builds. The `--no-edit` touch is # Cache cargo registry + target across builds. The mtime touch is
# belt-and-suspenders: cargo occasionally misses mtime-only changes on # belt-and-suspenders: cargo occasionally misses mtime-only changes on
# networked FS; this forces a fingerprint check. # networked FS; this forces a fingerprint check.
RUN --mount=type=cache,target=/usr/local/cargo/registry \ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/src/target,sharing=locked \ --mount=type=cache,target=/src/target,sharing=locked \
find crates -name '*.rs' -exec touch {} + && \ find crates -name '*.rs' -exec touch {} + && \
cargo build --release --bin pxeforge && \ cargo build --release --target x86_64-unknown-linux-musl --bin openpxe && \
cp target/release/pxeforge /pxeforge && \ cp target/x86_64-unknown-linux-musl/release/openpxe /openpxe && \
ls -l /pxeforge ls -l /openpxe
########## runtime ########## ########## runtime ##########
FROM debian:12-slim AS runtime FROM debian:12-slim AS runtime
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
ca-certificates libcap2-bin tini gosu iproute2 \ ca-certificates libcap2-bin tini gosu iproute2 \
wimtools samba nfs-common \ wimtools samba smbclient \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10001 --home-dir /var/lib/pxeforge --shell /usr/sbin/nologin pxeforge \ && useradd --system --uid 10001 --home-dir /var/lib/openpxe --shell /usr/sbin/nologin openpxe \
&& mkdir -p /var/lib/pxeforge/isos /var/lib/pxeforge/work /var/lib/pxeforge/smb \ && mkdir -p /var/lib/openpxe/isos /var/lib/openpxe/work /var/lib/openpxe/smb \
&& chown -R pxeforge:pxeforge /var/lib/pxeforge && chown -R openpxe:openpxe /var/lib/openpxe
# Runtime deps explained: # v0.4.5: the openpxe binary itself is now built against musl and is
# wimtools - provides `wimlib-imagex`, used to inject startnet.cmd into boot.wim. # fully static — no glibc dependency. The runtime stage still ships
# samba - `smbd` serves extracted Windows install media on :445 for WinPE # Debian slim because OpenPXE shells out to the packages below for
# to `net use`. Guest read-only, scoped to /var/lib/pxeforge/smb. # functionality we deliberately don't reimplement in-process:
# nfs-common - provides `mount.nfs` / `mount.nfs4` for the Storage tab's #
# NFS share manager. Mount also requires the container to run # wimtools - `wimlib-imagex`, used to inject startnet.cmd into boot.wim.
# with CAP_SYS_ADMIN — without it, mount(2) returns EPERM and # samba - `smbd` serves extracted Windows install media on :445 so
# the manager surfaces a clear error in the UI instead of # WinPE can `net use`. Guest read-only, scoped to
# failing silently. # /var/lib/openpxe/smb. This package provides the SERVER
# iproute2 - `ip addr` / `ip route` for the auto-detected Network tab # side only; the client CLI is a separate package below.
# fields (NIC name, subnet mask, default gateway). Tiny, # smbclient - v0.4.66: Samba's `smbclient` userspace CLI, used by
# always available; we don't pull in netlink crates for # the Storage tab's SMB shares manager to list and stream
# this one-shot startup probe. # ISOs from remote SMB servers without ever mounting them
# gosu - drops privileges cleanly from root after the entrypoint fixes # in the kernel. In Debian 12 `smbclient` is NOT pulled
# bind-mount ownership (common OpenShift/Docker UX issue). # in by the `samba` package — they're siblings, not
# Windows-specific tools only activate when the WebUI toggle is on. # 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
# for this one-shot startup probe.
# 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 + SMB legs to
# in-process Rust crates.
COPY --from=build /pxeforge /usr/local/bin/pxeforge COPY --from=build /openpxe /usr/local/bin/openpxe
COPY deploy/docker/entrypoint.sh /usr/local/bin/entrypoint.sh COPY deploy/docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh
# Grant the binary the ability to bind <1024 ports as a non-root user. # Grant the binary the ability to bind <1024 ports as a non-root user.
# This is the only capability PXEForge needs for proxy-mode DHCP + TFTP + HTTP. # This is the only capability OpenPXE needs for proxy-mode DHCP + TFTP + HTTP.
RUN setcap cap_net_bind_service=+ep /usr/local/bin/pxeforge RUN setcap cap_net_bind_service=+ep /usr/local/bin/openpxe
# IMPORTANT: we do NOT `USER pxeforge` here. The entrypoint runs as root, # IMPORTANT: we do NOT `USER openpxe` here. The entrypoint runs as root,
# chowns the mounted data dirs, then execs the binary via gosu as pxeforge. # chowns the mounted data dirs, then execs the binary via gosu as openpxe.
# OpenShift ignores USER directives anyway (it injects its own uid), and # OpenShift ignores USER directives anyway (it injects its own uid), and
# there entrypoint.sh's non-root branch just execs directly. # there entrypoint.sh's non-root branch just execs directly.
WORKDIR /var/lib/pxeforge WORKDIR /var/lib/openpxe
ENV PXEFORGE_ISO_DIR=/var/lib/pxeforge/isos \ ENV OPENPXE_ISO_DIR=/var/lib/openpxe/isos \
PXEFORGE_WORK_DIR=/var/lib/pxeforge/work \ OPENPXE_WORK_DIR=/var/lib/openpxe/work \
PXEFORGE_LOG=info,pxeforge=info OPENPXE_LOG=info,openpxe=info
EXPOSE 67/udp 69/udp 4011/udp 80/tcp 445/tcp EXPOSE 67/udp 69/udp 4011/udp 80/tcp 445/tcp
+9 -9
View File
@@ -2,9 +2,9 @@
# Container entrypoint that handles the common bind-mount-as-root case. # Container entrypoint that handles the common bind-mount-as-root case.
# #
# When volumes are bind-mounted into the container (e.g. `-v ./data/isos:...`), # When volumes are bind-mounted into the container (e.g. `-v ./data/isos:...`),
# they come up owned by the host uid:gid — often root:root. The pxeforge # they come up owned by the host uid:gid — often root:root. The openpxe
# binary runs as uid 10001 and can't write there. This script, when started # binary runs as uid 10001 and can't write there. This script, when started
# as root, chowns the two state dirs to the pxeforge user, then drops # as root, chowns the two state dirs to the openpxe user, then drops
# privileges via gosu before execing the binary. # privileges via gosu before execing the binary.
# #
# If the container is already running as non-root (OpenShift does this via # If the container is already running as non-root (OpenShift does this via
@@ -13,20 +13,20 @@
# or the operator is on their own for permissions. # or the operator is on their own for permissions.
set -e set -e
PXEFORGE_UID=${PXEFORGE_UID:-10001} OPENPXE_UID=${OPENPXE_UID:-10001}
PXEFORGE_GID=${PXEFORGE_GID:-10001} OPENPXE_GID=${OPENPXE_GID:-10001}
DATA_DIRS="/var/lib/pxeforge/isos /var/lib/pxeforge/work /var/lib/pxeforge/smb" DATA_DIRS="/var/lib/openpxe/isos /var/lib/openpxe/work /var/lib/openpxe/smb"
if [ "$(id -u)" = "0" ]; then if [ "$(id -u)" = "0" ]; then
for d in $DATA_DIRS; do for d in $DATA_DIRS; do
if [ -d "$d" ]; then if [ -d "$d" ]; then
chown -R "${PXEFORGE_UID}:${PXEFORGE_GID}" "$d" 2>/dev/null || true chown -R "${OPENPXE_UID}:${OPENPXE_GID}" "$d" 2>/dev/null || true
fi fi
done done
# Re-exec ourselves under the pxeforge user so the binary inherits a # Re-exec ourselves under the openpxe user so the binary inherits a
# clean process environment and a predictable umask. # clean process environment and a predictable umask.
exec gosu "${PXEFORGE_UID}:${PXEFORGE_GID}" /usr/local/bin/pxeforge "$@" exec gosu "${OPENPXE_UID}:${OPENPXE_GID}" /usr/local/bin/openpxe "$@"
fi fi
# Non-root: straight exec, no chown attempt. # Non-root: straight exec, no chown attempt.
exec /usr/local/bin/pxeforge "$@" exec /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
+1 -1
View File
@@ -1,7 +1,7 @@
apiVersion: v1 apiVersion: v1
kind: Namespace kind: Namespace
metadata: metadata:
name: pxeforge name: openpxe
labels: labels:
# Allow privileged pods (host-network) in this namespace only. The pod # Allow privileged pods (host-network) in this namespace only. The pod
# itself still runs non-root with only NET_BIND_SERVICE — privileged # itself still runs non-root with only NET_BIND_SERVICE — privileged
+11 -11
View File
@@ -1,5 +1,5 @@
--- ---
# Custom SCC for PXEForge. # Custom SCC for OpenPXE.
# #
# The default `restricted-v2` SCC blocks host network and all capabilities, # The default `restricted-v2` SCC blocks host network and all capabilities,
# which PXE cannot tolerate: DHCPDISCOVER is an L2 broadcast that CNI overlays # which PXE cannot tolerate: DHCPDISCOVER is an L2 broadcast that CNI overlays
@@ -19,10 +19,10 @@
apiVersion: security.openshift.io/v1 apiVersion: security.openshift.io/v1
kind: SecurityContextConstraints kind: SecurityContextConstraints
metadata: metadata:
name: pxeforge-scc name: openpxe-scc
annotations: annotations:
kubernetes.io/description: >- kubernetes.io/description: >-
Minimal SCC for PXEForge: host network + NET_BIND_SERVICE only, no raw Minimal SCC for OpenPXE: host network + NET_BIND_SERVICE only, no raw
sockets, no privileged mode. sockets, no privileged mode.
allowPrivilegedContainer: false allowPrivilegedContainer: false
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
@@ -54,27 +54,27 @@ volumes:
users: [] users: []
groups: [] groups: []
--- ---
# Bind the SCC to the pxeforge service account. # Bind the SCC to the openpxe service account.
kind: ClusterRole kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1 apiVersion: rbac.authorization.k8s.io/v1
metadata: metadata:
name: pxeforge-scc-use name: openpxe-scc-use
rules: rules:
- apiGroups: ["security.openshift.io"] - apiGroups: ["security.openshift.io"]
resources: ["securitycontextconstraints"] resources: ["securitycontextconstraints"]
resourceNames: ["pxeforge-scc"] resourceNames: ["openpxe-scc"]
verbs: ["use"] verbs: ["use"]
--- ---
kind: RoleBinding kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1 apiVersion: rbac.authorization.k8s.io/v1
metadata: metadata:
name: pxeforge-scc-use name: openpxe-scc-use
namespace: pxeforge namespace: openpxe
roleRef: roleRef:
apiGroup: rbac.authorization.k8s.io apiGroup: rbac.authorization.k8s.io
kind: ClusterRole kind: ClusterRole
name: pxeforge-scc-use name: openpxe-scc-use
subjects: subjects:
- kind: ServiceAccount - kind: ServiceAccount
name: pxeforge name: openpxe
namespace: pxeforge namespace: openpxe
+9 -9
View File
@@ -2,29 +2,29 @@
apiVersion: v1 apiVersion: v1
kind: ServiceAccount kind: ServiceAccount
metadata: metadata:
name: pxeforge name: openpxe
namespace: pxeforge namespace: openpxe
--- ---
apiVersion: v1 apiVersion: v1
kind: ConfigMap kind: ConfigMap
metadata: metadata:
name: pxeforge-config name: openpxe-config
namespace: pxeforge namespace: openpxe
data: data:
# Toggle DHCP proxy on or off. "proxy" = answer PXE clients alongside an # Toggle DHCP proxy on or off. "proxy" = answer PXE clients alongside an
# existing DHCP server. "disabled" = require operator to point an external # existing DHCP server. "disabled" = require operator to point an external
# DHCP at us via next-server/filename. # DHCP at us via next-server/filename.
PXEFORGE_DHCP_MODE: "proxy" OPENPXE_DHCP_MODE: "proxy"
# Override if auto-detection picks the wrong NIC in multi-homed pods. # Override if auto-detection picks the wrong NIC in multi-homed pods.
# Leave unset to auto-detect from the node's primary IPv4. # Leave unset to auto-detect from the node's primary IPv4.
# PXEFORGE_PUBLIC_IP: "10.0.0.5" # OPENPXE_PUBLIC_IP: "10.0.0.5"
PXEFORGE_LOG: "info,pxeforge=info" OPENPXE_LOG: "info,openpxe=info"
--- ---
apiVersion: v1 apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
metadata: metadata:
name: pxeforge-isos name: openpxe-isos
namespace: pxeforge namespace: openpxe
spec: spec:
# ReadWriteOnce is fine — we deploy as a single replica since DHCP proxy # ReadWriteOnce is fine — we deploy as a single replica since DHCP proxy
# coordination across replicas is not useful (clients hit whichever node # coordination across replicas is not useful (clients hit whichever node
+12 -12
View File
@@ -2,10 +2,10 @@
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: pxeforge name: openpxe
namespace: pxeforge namespace: openpxe
labels: labels:
app.kubernetes.io/name: pxeforge app.kubernetes.io/name: openpxe
spec: spec:
# Single replica by design (see PVC comment). If HA is needed later, split # Single replica by design (see PVC comment). If HA is needed later, split
# the HTTP/web plane (scalable, stateless) from the DHCP-proxy/TFTP plane # the HTTP/web plane (scalable, stateless) from the DHCP-proxy/TFTP plane
@@ -15,13 +15,13 @@ spec:
type: Recreate type: Recreate
selector: selector:
matchLabels: matchLabels:
app.kubernetes.io/name: pxeforge app.kubernetes.io/name: openpxe
template: template:
metadata: metadata:
labels: labels:
app.kubernetes.io/name: pxeforge app.kubernetes.io/name: openpxe
spec: spec:
serviceAccountName: pxeforge serviceAccountName: openpxe
# L2 broadcast (DHCPDISCOVER) does not cross most CNI overlays into # L2 broadcast (DHCPDISCOVER) does not cross most CNI overlays into
# pod netns. Host network is the working path. # pod netns. Host network is the working path.
hostNetwork: true hostNetwork: true
@@ -33,8 +33,8 @@ spec:
runAsUser: 10001 runAsUser: 10001
fsGroup: 10001 fsGroup: 10001
containers: containers:
- name: pxeforge - name: openpxe
image: ghcr.io/casperadmin/pxeforge:0.1.0 image: gitea.milesward.dev/mward4/openpxe:0.4.1
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- name: dhcp - name: dhcp
@@ -59,7 +59,7 @@ spec:
protocol: TCP protocol: TCP
envFrom: envFrom:
- configMapRef: - configMapRef:
name: pxeforge-config name: openpxe-config
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
readOnlyRootFilesystem: true readOnlyRootFilesystem: true
@@ -70,9 +70,9 @@ spec:
add: ["NET_BIND_SERVICE"] add: ["NET_BIND_SERVICE"]
volumeMounts: volumeMounts:
- name: isos - name: isos
mountPath: /var/lib/pxeforge/isos mountPath: /var/lib/openpxe/isos
- name: work - name: work
mountPath: /var/lib/pxeforge/work mountPath: /var/lib/openpxe/work
- name: tmp - name: tmp
mountPath: /tmp mountPath: /tmp
readinessProbe: readinessProbe:
@@ -97,7 +97,7 @@ spec:
volumes: volumes:
- name: isos - name: isos
persistentVolumeClaim: persistentVolumeClaim:
claimName: pxeforge-isos claimName: openpxe-isos
- name: work - name: work
emptyDir: {} emptyDir: {}
- name: tmp - name: tmp
+7 -7
View File
@@ -5,14 +5,14 @@
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: pxeforge name: openpxe
namespace: pxeforge namespace: openpxe
labels: labels:
app.kubernetes.io/name: pxeforge app.kubernetes.io/name: openpxe
spec: spec:
type: ClusterIP type: ClusterIP
selector: selector:
app.kubernetes.io/name: pxeforge app.kubernetes.io/name: openpxe
ports: ports:
- name: http - name: http
port: 80 port: 80
@@ -29,12 +29,12 @@ spec:
apiVersion: route.openshift.io/v1 apiVersion: route.openshift.io/v1
kind: Route kind: Route
metadata: metadata:
name: pxeforge name: openpxe
namespace: pxeforge namespace: openpxe
spec: spec:
to: to:
kind: Service kind: Service
name: pxeforge name: openpxe
weight: 100 weight: 100
port: port:
targetPort: http targetPort: http
+24 -24
View File
@@ -1,12 +1,12 @@
# PXEForge on Unraid # OpenPXE on Unraid
Three paths from "I have an Unraid box with Gitea on it" to "PXE clients Three paths from "I have an Unraid box with Gitea on it" to "PXE clients
boot from PXEForge". Pick the one that matches what you have. boot from OpenPXE". Pick the one that matches what you have.
## Path A — build on Unraid, push to Gitea registry, pull by tag ## Path A — build on Unraid, push to Gitea registry, pull by tag
Recommended once you've done it once. Image is published to Recommended once you've done it once. Image is published to
`gitea.milesward.dev/mward4/pxeforge:0.1.0` (or your equivalent) and `gitea.milesward.dev/mward4/openpxe:0.4.1` (or your equivalent) and
every Unraid template / docker-compose just references the tag. every Unraid template / docker-compose just references the tag.
Pre-flight: Pre-flight:
@@ -24,12 +24,12 @@ Run on the Unraid host (Settings → Terminal, or `ssh root@unraid`):
GITEA_TOKEN=<your-token> GITEA_TOKEN=<your-token>
curl -fsSL \ curl -fsSL \
-H "Authorization: token $GITEA_TOKEN" \ -H "Authorization: token $GITEA_TOKEN" \
http://localhost:3000/mward4/PXEForge/raw/branch/main/scripts/build-and-publish-unraid.sh \ http://localhost:3000/mward4/OpenPXE/raw/branch/main/scripts/build-and-publish-unraid.sh \
-o /tmp/pxeforge-publish.sh -o /tmp/openpxe-publish.sh
# Run it. ~6 min on Unraid hardware (native amd64, no QEMU). # Run it. ~6 min on Unraid hardware (native amd64, no QEMU).
chmod +x /tmp/pxeforge-publish.sh chmod +x /tmp/openpxe-publish.sh
GITEA_TOKEN=$GITEA_TOKEN /tmp/pxeforge-publish.sh GITEA_TOKEN=$GITEA_TOKEN /tmp/openpxe-publish.sh
``` ```
What it does: What it does:
@@ -40,24 +40,24 @@ What it does:
3. `docker build` against `deploy/docker/Dockerfile`. 3. `docker build` against `deploy/docker/Dockerfile`.
4. `docker login gitea.milesward.dev:3000` using a temp `DOCKER_CONFIG` 4. `docker login gitea.milesward.dev:3000` using a temp `DOCKER_CONFIG`
so the credential never lands in your real `~/.docker/config.json`. so the credential never lands in your real `~/.docker/config.json`.
5. `docker push` both `:0.1.0` and `:latest`. 5. `docker push` both `:0.4.1` and `:latest`.
6. Logout, scrub the temp config, delete the workspace. 6. Logout, scrub the temp config, delete the workspace.
After it finishes, in Unraid → Docker → Add Container, set: After it finishes, in Unraid → Docker → Add Container, set:
| Field | Value | | Field | Value |
|------------|-------------------------------------------------| |------------|-------------------------------------------------|
| Repository | `gitea.milesward.dev/mward4/pxeforge:0.1.0` | | Repository | `gitea.milesward.dev/mward4/openpxe:0.4.1` |
| Network | `host` | | Network | `host` |
| Extra args | `--cap-add=NET_BIND_SERVICE` | | Extra args | `--cap-add=NET_BIND_SERVICE` |
Volume mounts (paths inside container in **bold**): Volume mounts (paths inside container in **bold**):
- **`/var/lib/pxeforge/isos`** ↔ `/mnt/user/appdata/pxeforge/isos` - **`/var/lib/openpxe/isos`** ↔ `/mnt/user/appdata/openpxe/isos`
- **`/var/lib/pxeforge/work`** ↔ `/mnt/user/appdata/pxeforge/work` - **`/var/lib/openpxe/work`** ↔ `/mnt/user/appdata/openpxe/work`
- **`/var/lib/pxeforge/smb`** ↔ `/mnt/user/appdata/pxeforge/smb` - **`/var/lib/openpxe/smb`** ↔ `/mnt/user/appdata/openpxe/smb`
Or skip the manual UI by dropping `pxeforge.xml` (in this directory) Or skip the manual UI by dropping `openpxe.xml` (in this directory)
into `/boot/config/plugins/dockerMan/templates-user/` and Unraid will into `/boot/config/plugins/dockerMan/templates-user/` and Unraid will
list it as a one-click template. list it as a one-click template.
@@ -70,15 +70,15 @@ Skip the registry entirely. Useful for "hack on it locally" iterations.
```bash ```bash
ssh root@unraid ssh root@unraid
cd /mnt/user/appdata cd /mnt/user/appdata
git clone http://localhost:3000/mward4/PXEForge.git pxeforge-src git clone http://localhost:3000/mward4/OpenPXE.git openpxe-src
cd pxeforge-src cd openpxe-src
bash scripts/fetch-ipxe.sh bash scripts/fetch-ipxe.sh
docker compose -f docker-compose.yml up -d --build pxeforge docker compose -f docker-compose.yml up -d --build openpxe
``` ```
The bundled `docker-compose.yml` already wires host networking, the The bundled `docker-compose.yml` already wires host networking, the
right cap_add, and bind-mounts to `./data/`. Edit those bind-mount right cap_add, and bind-mounts to `./data/`. Edit those bind-mount
paths if you want them under `/mnt/user/appdata/pxeforge/`. paths if you want them under `/mnt/user/appdata/openpxe/`.
## Path C — `docker load` from a tarball I built off-box ## Path C — `docker load` from a tarball I built off-box
@@ -88,14 +88,14 @@ then:
```bash ```bash
# On the build host # On the build host
docker save pxeforge:0.1.0 | gzip > pxeforge-0.1.0.tar.gz docker save openpxe:0.4.1 | gzip > openpxe-0.4.1.tar.gz
# Transfer (rsync / scp / SMB / ZFS-replicate / sneakernet) # Transfer (rsync / scp / SMB / ZFS-replicate / sneakernet)
scp pxeforge-0.1.0.tar.gz root@unraid:/tmp/ scp openpxe-0.4.1.tar.gz root@unraid:/tmp/
# On Unraid # On Unraid
gunzip -c /tmp/pxeforge-0.1.0.tar.gz | docker load gunzip -c /tmp/openpxe-0.4.1.tar.gz | docker load
docker tag pxeforge:0.1.0 gitea.milesward.dev/mward4/pxeforge:0.1.0 docker tag openpxe:0.4.1 gitea.milesward.dev/mward4/openpxe:0.4.1
``` ```
If you want it pullable by tag from other Unraid templates, push to If you want it pullable by tag from other Unraid templates, push to
@@ -119,16 +119,16 @@ show up in the Dashboard's "Recent connections" table within seconds.
## Common gotchas ## Common gotchas
- **DHCP collision.** Don't run two PXE _proxies_ on the same broadcast - **DHCP collision.** Don't run two PXE _proxies_ on the same broadcast
domain. PXEForge runs in proxy mode and never offers IP leases, so domain. OpenPXE runs in proxy mode and never offers IP leases, so
it coexists with whatever DHCP server is already on the network — it coexists with whatever DHCP server is already on the network —
but two proxies racing each other will whichever-wins at random. but two proxies racing each other will whichever-wins at random.
- **Host networking only.** Bridge mode containers don't see broadcast - **Host networking only.** Bridge mode containers don't see broadcast
DHCP. There's no working bridge-mode config for a PXE server. DHCP. There's no working bridge-mode config for a PXE server.
- **Permissions on `/mnt/user/appdata/pxeforge`.** The container runs - **Permissions on `/mnt/user/appdata/openpxe`.** The container runs
as uid 10001 by default. The entrypoint chowns the bind mounts to as uid 10001 by default. The entrypoint chowns the bind mounts to
10001 on first start, but only if the container itself has root — 10001 on first start, but only if the container itself has root —
`--user=root` isn't needed; the multi-stage Dockerfile starts as `--user=root` isn't needed; the multi-stage Dockerfile starts as
root, fixes perms, then drops to pxeforge via gosu. root, fixes perms, then drops to openpxe via gosu.
- **NFS mounts in the Storage tab.** Mounting NFS inside the container - **NFS mounts in the Storage tab.** Mounting NFS inside the container
needs `CAP_SYS_ADMIN`. To enable, add `--cap-add=SYS_ADMIN` to the needs `CAP_SYS_ADMIN`. To enable, add `--cap-add=SYS_ADMIN` to the
Unraid template's "Extra args" — but understand that's a meaningful Unraid template's "Extra args" — but understand that's a meaningful
@@ -1,12 +1,12 @@
<?xml version="1.0"?> <?xml version="1.0"?>
<!-- <!--
Unraid Docker template for PXEForge. Unraid Docker template for OpenPXE.
Drop this file into /boot/config/plugins/dockerMan/templates-user/ Drop this file into /boot/config/plugins/dockerMan/templates-user/
on your Unraid box (or import via the Docker tab → "Add Container" → on your Unraid box (or import via the Docker tab → "Add Container" →
"Template Repositories" if you publish it on a Gitea raw URL). "Template Repositories" if you publish it on a Gitea raw URL).
IMPORTANT: PXEForge needs host networking for DHCP/TFTP raw broadcasts. IMPORTANT: OpenPXE needs host networking for DHCP/TFTP raw broadcasts.
Bridge mode will NOT work — clients can't see broadcast DHCP from a Bridge mode will NOT work — clients can't see broadcast DHCP from a
bridged container. The template forces NetworkType=host below. bridged container. The template forces NetworkType=host below.
@@ -21,21 +21,21 @@
Web UI: http://<unraid-ip>/ (port 80) Web UI: http://<unraid-ip>/ (port 80)
--> -->
<Container version="2"> <Container version="2">
<Name>PXEForge</Name> <Name>OpenPXE</Name>
<Repository>gitea.milesward.dev/mward4/pxeforge:latest</Repository> <Repository>gitea.milesward.dev/mward4/openpxe:latest</Repository>
<Registry>https://gitea.milesward.dev/mward4/-/packages/container/pxeforge</Registry> <Registry>https://gitea.milesward.dev/mward4/-/packages/container/openpxe</Registry>
<Network>host</Network> <Network>host</Network>
<MyIP/> <MyIP/>
<Shell>sh</Shell> <Shell>sh</Shell>
<Privileged>false</Privileged> <Privileged>false</Privileged>
<Support>https://gitea.milesward.dev/mward4/PXEForge/issues</Support> <Support>https://gitea.milesward.dev/mward4/OpenPXE/issues</Support>
<Project>https://gitea.milesward.dev/mward4/PXEForge</Project> <Project>https://gitea.milesward.dev/mward4/OpenPXE</Project>
<Overview> <Overview>
Air-gapped network PXE boot server. Container-native Rust Air-gapped network PXE boot server. Container-native Rust
implementation — DHCP proxy + TFTP + iPXE chainload + HTTP ISO implementation — DHCP proxy + TFTP + iPXE chainload + HTTP ISO
streaming, all in one process. Web UI for ISO upload, NFS share streaming, all in one process. Web UI for ISO upload, NFS share
mounting, and Gated Deployment ("horse-race" simultaneous launch mounting, and Queued Deployment for coordinated launch of one ISO
of one ISO across many waiting clients). across many waiting clients.
NEVER touches the client OS trust store: no test-signed drivers, NEVER touches the client OS trust store: no test-signed drivers,
no testsigning toggle, no httpdisk.sys. Windows boot uses vanilla no testsigning toggle, no httpdisk.sys. Windows boot uses vanilla
@@ -44,7 +44,7 @@
<Category>Network:Other Network:Management</Category> <Category>Network:Other Network:Management</Category>
<WebUI>http://[IP]/</WebUI> <WebUI>http://[IP]/</WebUI>
<TemplateURL/> <TemplateURL/>
<Icon>https://gitea.milesward.dev/mward4/PXEForge/raw/branch/main/crates/webui/src/logo.svg</Icon> <Icon>https://gitea.milesward.dev/mward4/OpenPXE/raw/branch/main/crates/webui/src/logo.svg</Icon>
<ExtraParams>--cap-add=NET_BIND_SERVICE</ExtraParams> <ExtraParams>--cap-add=NET_BIND_SERVICE</ExtraParams>
<PostArgs/> <PostArgs/>
<CPUset/> <CPUset/>
@@ -53,23 +53,23 @@
<DonateLink/> <DonateLink/>
<Requires> <Requires>
Host networking. Unraid&#39;s built-in DHCP server (if any) must Host networking. Unraid&#39;s built-in DHCP server (if any) must
not collide with a network that already has DHCP — PXEForge runs not collide with a network that already has DHCP — OpenPXE runs
in proxy mode and coexists, but only one DHCP _proxy_ should reply in proxy mode and coexists, but only one DHCP _proxy_ should reply
per broadcast domain. per broadcast domain.
</Requires> </Requires>
<Config Name="ISOs" Target="/var/lib/pxeforge/isos" Default="/mnt/user/appdata/pxeforge/isos" <Config Name="ISOs" Target="/var/lib/openpxe/isos" Default="/mnt/user/appdata/openpxe/isos"
Mode="rw" Description="Where uploaded and seeded .iso files live." Mode="rw" Description="Where uploaded and seeded .iso files live."
Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/pxeforge/isos</Config> Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/openpxe/isos</Config>
<Config Name="Work dir" Target="/var/lib/pxeforge/work" Default="/mnt/user/appdata/pxeforge/work" <Config Name="Work dir" Target="/var/lib/openpxe/work" Default="/mnt/user/appdata/openpxe/work"
Mode="rw" Description="Settings, NFS state, and runtime scratch. Persisted across restarts." Mode="rw" Description="Settings, NFS state, and runtime scratch. Persisted across restarts."
Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/pxeforge/work</Config> Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/openpxe/work</Config>
<Config Name="SMB share root" Target="/var/lib/pxeforge/smb" Default="/mnt/user/appdata/pxeforge/smb" <Config Name="SMB share root" Target="/var/lib/openpxe/smb" Default="/mnt/user/appdata/openpxe/smb"
Mode="rw" Description="Where extracted Windows install media lives. Only used when Windows toggle is on." Mode="rw" Description="Where extracted Windows install media lives. Only used when Windows toggle is on."
Type="Path" Display="advanced" Required="false" Mask="false">/mnt/user/appdata/pxeforge/smb</Config> Type="Path" Display="advanced" Required="false" Mask="false">/mnt/user/appdata/openpxe/smb</Config>
<Config Name="Public IP" Target="PXEFORGE_PUBLIC_IP" Default="" <Config Name="Public IP" Target="OPENPXE_PUBLIC_IP" Default=""
Mode="" Description="IP advertised to PXE clients. Leave blank to auto-detect; set explicitly on multi-homed Unraid hosts." Mode="" Description="IP advertised to PXE clients. Leave blank to auto-detect; set explicitly on multi-homed Unraid hosts."
Type="Variable" Display="always" Required="false" Mask="false"></Config> Type="Variable" Display="always" Required="false" Mask="false"></Config>
<Config Name="Log filter" Target="PXEFORGE_LOG" Default="info,pxeforge=debug" <Config Name="Log filter" Target="OPENPXE_LOG" Default="info,openpxe=debug"
Mode="" Description="tracing-subscriber EnvFilter expression." Mode="" Description="tracing-subscriber EnvFilter expression."
Type="Variable" Display="advanced" Required="false" Mask="false">info,pxeforge=debug</Config> Type="Variable" Display="advanced" Required="false" Mask="false">info,openpxe=debug</Config>
</Container> </Container>
+22 -22
View File
@@ -5,13 +5,13 @@
# 1. Local MVP test — host network, proxy-DHCP off (don't fight your # 1. Local MVP test — host network, proxy-DHCP off (don't fight your
# existing DHCP server on the LAN), TFTP + HTTP exposed on the host: # existing DHCP server on the LAN), TFTP + HTTP exposed on the host:
# #
# docker compose up pxeforge-dev # docker compose up openpxe-dev
# #
# 2. Real PXE deployment — host network, proxy-DHCP on, runs on a box # 2. Real PXE deployment — host network, proxy-DHCP on, runs on a box
# plugged into the PXE network: # plugged into the PXE network:
# #
# # First set PXEFORGE_PUBLIC_IP to this host's LAN address in .env # # First set OPENPXE_PUBLIC_IP to this host's LAN address in .env
# docker compose up pxeforge # docker compose up openpxe
# #
# On Linux hosts, `network_mode: host` gives the container direct access to # On Linux hosts, `network_mode: host` gives the container direct access to
# the physical NIC — required for DHCP proxy because CNI overlays and Docker # the physical NIC — required for DHCP proxy because CNI overlays and Docker
@@ -19,13 +19,13 @@
# #
# On macOS / Windows hosts, `network_mode: host` is limited — the daemon # On macOS / Windows hosts, `network_mode: host` is limited — the daemon
# runs in a Linux VM (Colima/Docker Desktop) so the "host" network is the # runs in a Linux VM (Colima/Docker Desktop) so the "host" network is the
# VM, not your Mac. Proxy-DHCP is not feasible on macOS; use `pxeforge-dev` # VM, not your Mac. Proxy-DHCP is not feasible on macOS; use `openpxe-dev`
# with published ports and set DHCP-MODE=disabled. # with published ports and set DHCP-MODE=disabled.
services: services:
# Real PXE deployment (Linux hosts). # Real PXE deployment (Linux hosts).
pxeforge: openpxe:
image: pxeforge:0.1.0 image: openpxe:0.1.0
build: build:
context: . context: .
dockerfile: deploy/docker/Dockerfile dockerfile: deploy/docker/Dockerfile
@@ -34,33 +34,33 @@ services:
environment: environment:
# REQUIRED on multi-homed hosts. Set to this machine's LAN IP so the # REQUIRED on multi-homed hosts. Set to this machine's LAN IP so the
# advertised iPXE URLs actually resolve from the PXE clients. Without # advertised iPXE URLs actually resolve from the PXE clients. Without
# this, PXEForge will refuse to start rather than advertise a # this, OpenPXE will refuse to start rather than advertise a
# loopback address that can't be reached. # loopback address that can't be reached.
PXEFORGE_PUBLIC_IP: ${PXEFORGE_PUBLIC_IP:?set this to the host LAN IP} OPENPXE_PUBLIC_IP: ${OPENPXE_PUBLIC_IP:?set this to the host LAN IP}
PXEFORGE_DHCP_MODE: proxy OPENPXE_DHCP_MODE: proxy
PXEFORGE_LOG: info OPENPXE_LOG: info
volumes: volumes:
- ./data/isos:/var/lib/pxeforge/isos - ./data/isos:/var/lib/openpxe/isos
- ./data/work:/var/lib/pxeforge/work - ./data/work:/var/lib/openpxe/work
# Dev / MVP container: published ports, DHCP disabled, HTTP on 8080. # Dev / MVP container: published ports, DHCP disabled, HTTP on 8080.
# Use this on laptops where you want to curl the API or UI without # Use this on laptops where you want to curl the API or UI without
# running an actual PXE chain. # running an actual PXE chain.
pxeforge-dev: openpxe-dev:
image: pxeforge:0.1.0 image: openpxe:0.1.0
build: build:
context: . context: .
dockerfile: deploy/docker/Dockerfile dockerfile: deploy/docker/Dockerfile
environment: environment:
PXEFORGE_PUBLIC_IP: ${PXEFORGE_PUBLIC_IP:-127.0.0.1} OPENPXE_PUBLIC_IP: ${OPENPXE_PUBLIC_IP:-127.0.0.1}
PXEFORGE_DHCP_MODE: disabled OPENPXE_DHCP_MODE: disabled
PXEFORGE_HTTP_PORT: "8080" OPENPXE_HTTP_PORT: "8080"
PXEFORGE_TFTP_PORT: "6969" OPENPXE_TFTP_PORT: "6969"
PXEFORGE_DHCP_PORT: "6767" OPENPXE_DHCP_PORT: "6767"
PXEFORGE_LOG: info,pxeforge=debug OPENPXE_LOG: info,openpxe=debug
ports: ports:
- "8080:8080/tcp" - "8080:8080/tcp"
- "6969:6969/udp" - "6969:6969/udp"
volumes: volumes:
- ./data/isos:/var/lib/pxeforge/isos - ./data/isos:/var/lib/openpxe/isos
- ./data/work:/var/lib/pxeforge/work - ./data/work:/var/lib/openpxe/work
+156
View File
@@ -0,0 +1,156 @@
# Phase 6 — recommendations
The v0.4.1 cut leaves OpenPXE in a state where the entire protocol stack
and operator UI are exercised by the automated test suite, the container is
multi-arch buildable, and the image ships at ~97 MB. What's left before
this looks and feels like a 1.0 product is mostly **real-hardware
validation** plus a small batch of features that can only sensibly be
designed once we've watched real machines image.
This doc is a punch list, ordered by what I'd do first if I had a week.
## Tier 1 — must-do before we call anything "stable"
### 1. Real-hardware validation matrix
We have CI tests for every protocol leg, but no end-to-end PXE on real
firmware. Build a small matrix:
| client | firmware | OS family | pass criteria |
|-------------------------------------|-----------|------------|---------------------------|
| any 10-y-old mini-PC | Legacy BIOS | Ubuntu Server 24.04 | gets to GRUB / installer |
| Intel NUC / similar | UEFI x64 | Windows 11 | reaches "where do you want to install" |
| Raspberry Pi 4 | UEFI ARM64 | Raspberry Pi OS | gets to login prompt |
| Dell / HP business laptop | UEFI x64 | Fedora | one of: kernel boot or wimboot |
Add a `docs/HARDWARE_VALIDATION.md` checklist that records what worked,
firmware versions, and any quirks. Anything weird gets a regression
test in the relevant crate.
### 2. Boot menu hotkey + UI accessibility audit
The iPXE menu has number-key + letter hotkeys but no documentation on
what they map to. Generate a printable cheat-sheet from
`crates/http-api/src/ipxe_script.rs` so operators don't have to read
the source. Run a screen-reader pass over the web UI — most of it
should be fine since we're mostly tables + form labels, but the
Terminal pane and the SSE log output need explicit `aria-live`
regions.
### 3. Boot.wim re-patch detection
Bootimus v0.1.62's "fingerprint of patched inputs + Save & Re-patch"
pattern is a small but high-value feature: when an operator changes
the SMB host override or upgrades wimboot, the existing patched
boot.wim is silently stale. We should:
- Hash the inputs (smb_host, smb_share, startnet.cmd content,
wimboot binary digest) into the IsoMeta;
- Surface a "needs re-patch" warning on the Storage tab when the
hash drifts;
- Add a "Re-patch SMB" button that re-runs the WimPatcher.
## Tier 2 — features that round out pre-beta
### 4. Auto-install file library
iVentoy and Bootimus both support attaching `autounattend.xml` /
`preseed.cfg` / `kickstart.cfg` to an image. The mechanics are
straightforward: store files under `<work_dir>/autoinstall/<distro>/`,
expose CRUD via `/api/autoinstall-files`, and modify the WimPatcher
+ Linux kernel cmdline to fetch + apply the right file. Placeholders
worth supporting (Bootimus pattern): `{{MAC}}`, `{{HOSTNAME}}`,
`{{IP}}`, `{{SERVER_ADDR}}`, `{{IMAGE_FILENAME}}`, substituted
serve-side per request.
### 5. Wake-on-LAN trigger
A natural pair with per-MAC host bindings: bind a MAC to an image,
then click "Wake & Image" to send the magic packet and let OpenPXE
do the rest. Implementation is small (`udp/9` broadcast, magic packet
construction) but it makes the bound-host workflow feel instant.
### 6. Distro profile manifest
Today, distro detection lives as Rust match arms in `introspect.rs`
and the kernel cmdline templates live in `store.rs`. Bootimus extracts
this into a JSON manifest that ships embedded in the binary AND is
overridable by the operator at runtime — so a new distro can be added
without rebuilding the container. Worth porting; it'd let community
contributions land as PRs to a single JSON file.
### 7. Syslog receiver
`smee` ships one. The use case: WinPE / Linux installers can be
configured to syslog over the network to the PXE server; if we have
an endpoint and a place in the UI to view per-client diagnostics,
post-mortem on a failed install gets dramatically easier.
### 8. UEFI HTTP Boot validation
Option 60 = `HTTPClient` is wired up in `decide()` already, but
we've never tested it on real firmware. Some Dell + Lenovo UEFIs
prefer it over PXE-via-TFTP. A quick check on a real machine
(disable TFTP boot in firmware, force HTTP boot) and a regression
test would be nice.
## Tier 3 — bigger lifts, only if there's demand
### 9. Pure-Rust SMB server
`smbd` from Samba is ~80 MB of the runtime image. There are pure-Rust
SMB2 server crates (`smbd-server`, `smb-rs`) of varying maturity.
Replacing the dep would slim the image by ~40% and remove the
`CAP_SYS_ADMIN` requirement for SMB. Worth a spike, not necessarily
landable in Phase 6.
### 10. IPv6 / DHCPv6
PXE-over-IPv6 is real (RFC 5970). Some sites are v6-only. Worth
implementing once we know we have one. Until then, IPv4-only is the
right default — flipping the bit on v6 without v6 testing is asking
for silent breakage.
### 11. Multi-replica deployment
The current design assumes one OpenPXE per broadcast domain. Two
proxies on the same L2 will race; the deployment queue is in-memory, etc.
For HA we'd need to:
- Externalize the deployment queue (Redis, etcd) or lean into "the menu is
cheap to refetch if a replica dies";
- Ensure DHCP proxy replies are deterministic so a client always
gets the same answer regardless of which replica replied;
- Document the L2 collision domain story.
This is a large lift and should only happen if someone's actually
asking for it.
### 12. Pi 4 / SBC quirks
Raspberry Pi netboot uses a specific DHCP option-43 vendor field +
TFTP path layout that OpenPXE doesn't currently special-case. There's
a spec; the work is small once we have a Pi to test on.
## What I'd skip
- **A custom DHCP server (not proxy).** The proxy mode is the right
abstraction; full DHCP would need raw sockets + a lot of corner-case
handling for problems no operator wants us to solve.
- **A pluggable backend abstraction à la Tinkerbell.** Tinkerbell does
it because they integrate with k8s CRDs. OpenPXE's "the file system
IS the database" model is simpler and good enough for the target
audience. Don't add a Backend trait until something asks for it.
- **Multiple language UIs.** Bootimus added these in v0.1.62 and the
translations are LLM-generated. Skip until we have real users
asking for non-English.
## Quick wins (could land in a single afternoon)
- Add a Grafana dashboard JSON to `deploy/grafana/` driven off the
new `/metrics` endpoint.
- A `openpxe bench` subcommand that runs a 10-second internal load
test (synthetic queue joins) so an operator can sanity-check tuning.
- Ship a basic `docker-compose.yml` for the Unraid path that demos
the new themes / progress widget.
- Generate a printable single-page operator runbook from the README
+ architecture.md (e.g. `cargo xtask runbook`).
+100 -27
View File
@@ -1,4 +1,4 @@
# PXEForge architecture # OpenPXE architecture
## Protocol stack ## Protocol stack
@@ -8,7 +8,7 @@ Client firmware PXE ROM
│ DHCPDISCOVER (UDP/67 broadcast, option 60 "PXEClient", option 93 arch) │ DHCPDISCOVER (UDP/67 broadcast, option 60 "PXEClient", option 93 arch)
┌─────────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────────┐
PXEForge OpenPXE
│ │ │ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ DHCP proxy │ │ TFTP server │ │ HTTP server (axum) │ │ │ │ DHCP proxy │ │ TFTP server │ │ HTTP server (axum) │ │
@@ -33,7 +33,7 @@ Client runs iPXE
│ DHCPDISCOVER with option 77 "iPXE" │ DHCPDISCOVER with option 77 "iPXE"
PXEForge sees user-class "iPXE" → replies with HTTP URL: /boot.ipxe OpenPXE sees user-class "iPXE" → replies with HTTP URL: /boot.ipxe
│ HTTP GET /boot.ipxe (iPXE menu, auto-generated from IsoStore) │ HTTP GET /boot.ipxe (iPXE menu, auto-generated from IsoStore)
@@ -48,14 +48,14 @@ Kernel boots with distro-specific args pointing back at /iso/<id>.iso
| Crate | Responsibility | | Crate | Responsibility |
|---------------------|-------------------------------------------------------------------| |---------------------|-------------------------------------------------------------------|
| `pxeforge-core` | Shared types: `Config`, `ClientArch`, `FirmwareClass`, `ClientRegistry` | | `openpxe-core` | Shared types: `Config`, `ClientArch`, `FirmwareClass`, `ClientRegistry` |
| `pxeforge-ipxe-assets` | Embeds bundled iPXE binaries via `rust-embed` | | `openpxe-ipxe-assets` | Embeds bundled iPXE binaries via `rust-embed` |
| `pxeforge-iso-store` | On-disk ISO store, introspection, boot-entry generation | | `openpxe-iso-store` | On-disk ISO store, introspection, boot-entry generation |
| `pxeforge-dhcp-proxy` | UDP listener + `dhcproto` reply builder; pure `decide()` unit-testable | | `openpxe-dhcp-proxy` | UDP listener + `dhcproto` reply builder; pure `decide()` unit-testable |
| `pxeforge-tftp` | RFC 1350 + OACK (blksize / tsize / windowsize). Serves only embedded assets — no filesystem | | `openpxe-tftp` | RFC 1350 + OACK (blksize / tsize / windowsize). Serves only embedded assets — no filesystem |
| `pxeforge-http-api` | `axum` router: web UI, API, iPXE script generation, ISO streaming | | `openpxe-http-api` | `axum` router: web UI, API, iPXE script generation, ISO streaming |
| `pxeforge-webui` | Single `index.html` served as static string | | `openpxe-webui` | Single `index.html` served as static string |
| `pxeforge` (bin) | Wires everything together, runs the three servers concurrently | | `openpxe` (bin) | Wires everything together, runs the three servers concurrently |
## Key decisions and why ## Key decisions and why
@@ -75,11 +75,11 @@ so plain `SOCK_DGRAM` is enough.
1. Firmware PXE ROM sends DHCPDISCOVER with option 60 = `PXEClient`, 1. Firmware PXE ROM sends DHCPDISCOVER with option 60 = `PXEClient`,
option 93 = arch. option 93 = arch.
2. PXEForge replies with TFTP server + arch-specific iPXE binary 2. OpenPXE replies with TFTP server + arch-specific iPXE binary
(`undionly.kpxe` for Legacy BIOS, `snponly.efi` for x86_64 UEFI, etc.). (`undionly.kpxe` for Legacy BIOS, `snponly.efi` for x86_64 UEFI, etc.).
3. Client TFTPs the iPXE binary and runs it. 3. Client TFTPs the iPXE binary and runs it.
4. iPXE does its own DHCP, setting option 77 (user-class) to `iPXE`. 4. iPXE does its own DHCP, setting option 77 (user-class) to `iPXE`.
5. PXEForge detects the user-class and this time replies with an HTTP URL 5. OpenPXE detects the user-class and this time replies with an HTTP URL
in option 67 pointing at `/boot.ipxe`. in option 67 pointing at `/boot.ipxe`.
6. iPXE fetches and executes that script, which chains the selected OS. 6. iPXE fetches and executes that script, which chains the selected OS.
@@ -105,7 +105,7 @@ Whatever we do for Windows, we never:
- instruct users to enable `bcdedit /set testsigning on` - instruct users to enable `bcdedit /set testsigning on`
- install any certificate into the target's root/trust store - install any certificate into the target's root/trust store
iVentoy's `httpdisk.sys` approach broke this rule. PXEForge doesn't. iVentoy's `httpdisk.sys` approach broke this rule. OpenPXE doesn't.
### Linux ISO boot uses kernel+initrd extraction, not sanboot ### Linux ISO boot uses kernel+initrd extraction, not sanboot
@@ -157,13 +157,13 @@ release:
root-owned" problem that breaks ISO upload on standard Docker hosts. root-owned" problem that breaks ISO upload on standard Docker hosts.
- `/healthz` and `/readyz` split from `/api/status` — readyz fails if no - `/healthz` and `/readyz` split from `/api/status` — readyz fails if no
iPXE binaries are bundled, giving K8s probes a real signal. iPXE binaries are bundled, giving K8s probes a real signal.
- Startup aborts with a clear error if `PXEFORGE_PUBLIC_IP` can't be - Startup aborts with a clear error if `OPENPXE_PUBLIC_IP` can't be
auto-detected (no more silent `127.0.0.1` advertisement). auto-detected (no more silent `127.0.0.1` advertisement).
- `scripts/fetch-ipxe.sh` fails non-zero if zero binaries download; the - `scripts/fetch-ipxe.sh` fails non-zero if zero binaries download; the
Dockerfile uses arch-scoped paths (`x86_64-efi/snponly.efi` etc.). Dockerfile uses arch-scoped paths (`x86_64-efi/snponly.efi` etc.).
**Windows boot plumbing** (new): **Windows boot plumbing** (new):
- `pxeforge-iso-store::smb::SmbManager` supervises `smbd` on the Windows - `openpxe-iso-store::smb::SmbManager` supervises `smbd` on the Windows
toggle: `start` → spawn + write `smb.conf`; `reconcile` → SIGHUP on toggle: `start` → spawn + write `smb.conf`; `reconcile` → SIGHUP on
share changes; `stop` → SIGTERM. `SmbState` surfaced to the UI for share changes; `stop` → SIGTERM. `SmbState` surfaced to the UI for
visibility. visibility.
@@ -177,32 +177,32 @@ release:
- ISO sizes in menu labels (`[ 4376 MB]`), iVentoy format. - ISO sizes in menu labels (`[ 4376 MB]`), iVentoy format.
- `Reboot Computer` + `Exit and continue BIOS boot` in Tools menu. - `Reboot Computer` + `Exit and continue BIOS boot` in Tools menu.
- Number-key hotkeys (1..9) on boot entries, letter hotkeys on tools. - Number-key hotkeys (1..9) on boot entries, letter hotkeys on tools.
- Clients tab cross-joins the gate queue so an operator sees "at gate #2" - Clients tab cross-joins the deployment queue so an operator sees "in queue #2"
or "assigned: ubuntu-linux" status inline. or "assigned: ubuntu-linux" status inline.
**Developer ergonomics** (new): **Developer ergonomics** (new):
- `pxeforge seed --from <path>` CLI to import ISOs from a directory. - `openpxe seed --from <path>` CLI to import ISOs from a directory.
Same pipeline as web upload (slug, sha256, introspection, boot-entry). Same pipeline as web upload (slug, sha256, introspection, boot-entry).
- `docker-compose.yml` with `pxeforge` (host network, real PXE) and - `docker-compose.yml` with `openpxe` (host network, real PXE) and
`pxeforge-dev` (published ports, DHCP disabled, for API testing). `openpxe-dev` (published ports, DHCP disabled, for API testing).
**API cleanliness**: **API cleanliness**:
- All timestamps now serialized as RFC 3339 strings (the `time` crate's - All timestamps now serialized as RFC 3339 strings (the `time` crate's
default 9-tuple broke browser `Date` parsing). default 9-tuple broke browser `Date` parsing).
- Gate poll retains assignment until the operator releases it; if the - Queue poll retains assignment until the operator releases it; if the
client's chain fails, it reuses the assignment instead of falling back client's chain fails, it reuses the assignment instead of falling back
to the menu. to the menu.
## Phase 4 — UI restructure + remote storage ## Phase 4 — UI restructure + remote storage
The web UI was rebuilt around six tabs (Dashboard / Network / Forge Gate / The web UI was rebuilt around six tabs (Dashboard / Network / Queue /
Storage / Terminal / About) inspired by the iVentoy layout the user Storage / Terminal / About) inspired by the iVentoy layout the user
attached and Netbox Labs's compact-card pattern. The old hierarchical attached and Netbox Labs's compact-card pattern. The old hierarchical
"Monitoring / Content / Configuration" sidebar grouping is gone — every "Monitoring / Content / Configuration" sidebar grouping is gone — every
tab is one click from the brand bar. tab is one click from the brand bar.
**NFS share manager** (`crates/iso-store/src/nfs.rs`): **NFS share manager** (`crates/iso-store/src/nfs.rs`):
- Operators add a remote share via Storage → NFS shares; PXEForge mounts - Operators add a remote share via Storage → NFS shares; OpenPXE mounts
it under `<work_dir>/nfs/<id>/` and walks it for `*.iso` files. it under `<work_dir>/nfs/<id>/` and walks it for `*.iso` files.
- Each ISO found is registered with `IsoStore::register_external` using - Each ISO found is registered with `IsoStore::register_external` using
a new `IsoSource::Nfs { mount_id, relative_path }` variant. The store a new `IsoSource::Nfs { mount_id, relative_path }` variant. The store
@@ -227,7 +227,7 @@ tab is one click from the brand bar.
followed by live updates. Slow clients see a `lagged` event rather followed by live updates. Slow clients see a `lagged` event rather
than dropping the stream. than dropping the stream.
- `/api/terminal` accepts a single command line and dispatches to a - `/api/terminal` accepts a single command line and dispatches to a
whitelist (`status`, `isos`, `clients`, `gate {list,assign,release}`, whitelist (`status`, `isos`, `clients`, `queue {list,assign,release}`,
`nfs {list,mount,unmount,scan}`, `smb {status,start,stop,reload}`, `nfs {list,mount,unmount,scan}`, `smb {status,start,stop,reload}`,
`log {clear,tail}`). Output is mirrored onto the LogBus so reading the `log {clear,tail}`). Output is mirrored onto the LogBus so reading the
live tail tells the same story as scrolling the terminal pane. live tail tells the same story as scrolling the terminal pane.
@@ -240,7 +240,7 @@ tab is one click from the brand bar.
read-only by design — silently changing the public IP on a hot UI read-only by design — silently changing the public IP on a hot UI
would break PXE for every client mid-boot. would break PXE for every client mid-boot.
- The only writable network field is `dns_server`, an optional - The only writable network field is `dns_server`, an optional
informational hint stored in `Settings`. PXEForge does not run a DNS informational hint stored in `Settings`. OpenPXE does not run a DNS
server; the field exists so operators don't have to dig out the server; the field exists so operators don't have to dig out the
upstream DNS at 3 AM. upstream DNS at 3 AM.
@@ -252,11 +252,73 @@ tab is one click from the brand bar.
warning. warning.
- Dashboard surfaces a "Images that won't boot" panel reusing the same - Dashboard surfaces a "Images that won't boot" panel reusing the same
predicate, so the operator sees the problem before they pick the ISO predicate, so the operator sees the problem before they pick the ISO
in the gate. in the queue.
- Streaming uploads are already in place via axum multipart; the v0.1.62 - Streaming uploads are already in place via axum multipart; the v0.1.62
fix to "502 on big upload" doesn't apply. fix to "502 on big upload" doesn't apply.
## What's deferred to Phase 5 ## Phase 5 — pre-beta hardening
**Per-MAC host bindings** (`crates/core/src/host_bindings.rs`):
- New `HostBindings` registry maps a MAC → preferred `BootEntry::id`
(or one of the reserved menu shortcuts (`_local`, `_queue``,
`_tools_menu`).
- Persisted to `<work_dir>/hosts.json`. Like `SettingsStore`, in-memory
is authoritative — disk corruption falls back to empty rather than
failing startup.
- The DHCP reply embeds `?mac=${mac}` in the boot.ipxe URL; iPXE
substitutes the literal MAC client-side, so the HTTP layer can
short-circuit past the menu when a binding exists.
- `/api/hosts` GET / POST / DELETE drives the **Hosts** tab.
**Prometheus metrics** (`crates/core/src/metrics.rs`):
- Lock-free `AtomicU64`-backed counters + gauges. No `prometheus` /
`metrics-rs` dep — they bring a registry, runtime, and complexity
we don't need for a fixed set of metric families.
- Counters: DHCP replies (per arch label), DHCP declined, TFTP
transfers (per status label), TFTP bytes, HTTP requests (per route
label).
- Gauges: ISO count, client count, queue count, queue-imaging count,
NFS active mounts, uptime, build info.
- Exposed as plain Prometheus text at `/metrics`.
**Code cleanup pass**: clippy `--workspace --all-targets` is now
warning-free. Replaced `format!()`-into-`String` with
`std::fmt::Write::write!`, switched manual reverse comparators to
`Reverse`, fixed `map_or(false, …)``is_some_and`, and a handful of
other idiom fixes.
**UI overhaul** for the pre-beta milestone:
- Light + dark themes via `:root[data-theme=light]` token swap.
Toggled by a top-right button or the `T` key. Persisted in
localStorage; pre-paint inline script avoids dark→light flash.
- New SVG logos: a refined OpenPXE mark (`logo.svg`) and a compact
SMIL-animated loader (`loader.svg`). Pure SVG, embedded in the binary.
- Deployment progress widget on the Dashboard and Queue: animated
OpenPXE mark paired with a `linear-gradient(warn → accent)` progress bar
with a moving sheen. Goes idle (greyscale, no sheen) at zero
imaging load.
- Loader replaced "Loading..." text with the same OpenPXE mark.
- Sidebar gains a **Hosts** tab.
**Windows boot validation**:
- New integration test synthesizes an ISO9660 with the `SOURCES\BOOT.WIM`
sentinel, uploads it, and 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, and
4. **no** trust-store strings appear: `bcdedit`, `testsigning`,
`certutil`, `httpdisk`, `test-signed` are all explicitly
forbidden in the rendered output.
- WinPE bootstrap (`startnet.cmd`) now picks up Bootimus v0.1.58
fixes: explicit `net start Workstation` before `net use`, surfaces
errors instead of blind retries.
**Test posture**: protocol, HTTP, ISO-store, Windows script, queue, metrics,
and UI-offline checks all run in the workspace test suite.
## What's deferred to Phase 6
- Full ISO9660 + Joliet + Rock Ridge parser (current lookup is plain ISO9660 — Debian ISOs with Rock Ridge extensions may miss some paths). - Full ISO9660 + Joliet + Rock Ridge parser (current lookup is plain ISO9660 — Debian ISOs with Rock Ridge extensions may miss some paths).
- Real-hardware Windows boot validation (plumbing tested; no MS ISO pushed through the full pipeline yet). - Real-hardware Windows boot validation (plumbing tested; no MS ISO pushed through the full pipeline yet).
@@ -268,3 +330,14 @@ tab is one click from the brand bar.
- Pure-Rust SMB server (replace smbd) — slim image, no Samba. - Pure-Rust SMB server (replace smbd) — slim image, no Samba.
- Auto-install / autounattend file library (Bootimus v0.1.58 pattern). - Auto-install / autounattend file library (Bootimus v0.1.58 pattern).
- Per-client / per-group menus (Bootimus v0.1.16 pattern). - Per-client / per-group menus (Bootimus v0.1.16 pattern).
- Real-hardware integration: at minimum a Linux ISO booted on a real
BIOS box, a Windows ISO booted via wimboot on a real UEFI box, and a
Pi 4 booting from an NFS-mounted Raspberry Pi OS ISO.
- Distro profile manifest (Bootimus v0.1.27 pattern) — currently
introspection logic is hard-coded; could become data-driven so an
operator can add a new distro profile from the UI without rebuilding.
- Wake-on-LAN trigger (Bootimus v0.1.16 pattern) — power-on a host then
imaging starts unattended via a per-MAC binding.
- Syslog receiver (smee feature) — capture client-side install syslog
for diagnostic visibility.
- IPv6 PXE / DHCPv6 — currently IPv4 only.
+106
View File
@@ -0,0 +1,106 @@
# PXE menu theme — research for next-release follow-up
Status: queued. v0.4.63 keeps the ASCII-banner fallback + `console --picture`
compositor wired; this note captures the design for the menu-theming work
that lands once iPXE rebuilt with `IMAGE_PNG` is published.
## How iVentoy actually does it
iVentoy is closed-source for its menu, but the supporting bits are
public at https://github.com/ventoy/PXE — a vanilla iPXE snapshot
(`iPXE/ipxe-bd13697`) used to produce the loader binaries iVentoy
serves over TFTP (`pxeboot.efi`, `iventoy_loader_16000`,
`iventoy_loader_16000_uefi`).
The graphical menu itself is rendered by iPXE's framebuffer console
with a baked-in PNG background via `console --picture` — same
primitive OpenPXE already uses in `crates/http-api/src/ipxe_script.rs`.
Evidence:
- The iPXE build in `ventoy/PXE` is configured with `CONSOLE_FRAMEBUFFER`
+ `IMAGE_PNG` + `CONSOLE_CMD` (the three flags `console --picture`
needs).
- iVentoy issue #11 confirms "iventoy using default 1024x768"; users
report 800x600 / 1024x768 / 1280x720 / 1280x1024 / 1920x1080 as
selectable resolutions from the iVentoy web UI **Configuration tab**,
not via EDID auto-detect. iPXE has no EDID parsing; the daemon writes
a resolution-tagged script per boot and serves the matching PNG.
- iVentoy docs explicitly state both Free and Pro editions **do not
support** modifying the boot background/title — it's baked into the
shipped PNG assets.
- Chrome is iPXE's native `menu` / `item` / `choose` widgets (single
highlight bar, no borders) painted on top of the PNG, with margins
set via `console --left/--right/--top/--bottom` to keep the text off
the logo. Not GRUB, not syslinux — UEFI iVentoy uses iPXE's
`snponly.efi` / `pxeboot.efi`, and `--picture` does work under UEFI
GOP despite older folklore.
Do not conflate this with Ventoy-USB, which is a separate codebase and
uses GRUB2 themes (`theme.txt`, `background_ventoy.png`, `select_c.png`).
## Rust ingredients to replicate / surpass
Most of these already exist in the workspace.
1. **Compositor (extend, don't replace)** — extend
`crates/iso-store/src/pxe_logo.rs` to emit per-resolution PNGs
(1024x768, 1280x1024, 1920x1080 as the v1 set). `image` +
`imageproc` crates handle scaling; `ab_glyph` / `fontdue` for raster
text (subtitle, hostname, version). One source SVG/logo, three to
five rendered PNGs cached on disk.
2. **Script generator**`ipxe_script.rs` already emits
`console --picture … || console`. Add a `?res=` query param (or
per-MAC client hint persisted in `hosts.json`) and serve the matching
PNG plus matching `console --x --y` line. Keep the text-console
fallback already in place.
3. **Resolution selection** — iPXE exposes `${vesa-x}` / `${vesa-y}` on
BIOS; UEFI side we can probe firmware vars at chain-time. The simpler
v1 is a "low-res / hi-res" toggle in Settings plus a per-host
override — mirrors iVentoy's UX, no kernel helper needed. True EDID
parsing is overkill for the first cut.
4. **Chrome upgrades over iVentoy** — iPXE menus are limited (single
highlight, no borders). To look distinctly cooler without leaving
iPXE: paint border / title / footer **into the PNG**, leave a window
in the middle, then `console --left/--right/--top/--bottom` to inset
the iPXE menu exactly into that window. ASCII box-drawing inside the
menu remains fragile (iPXE mangles non-ASCII on some builds — already
noted in `ipxe_script.rs`).
## Recommended architecture for the next OpenPXE release
- Build a `pxe_theme` module beside `pxe_logo.rs`: takes operator logo
+ theme tokens (accent colour, title, footer) and renders a layered
PNG (background gradient → framing chrome → logo → title bar → footer
with `${hostname}` / `${version}` / `${ip}`) at the three target
resolutions. Cache by hash of inputs.
- Serve at `/branding/pxe-menu-{w}x{h}.png`. Default 1024x768; expose a
Settings dropdown.
- In `ipxe_script.rs`, emit
`console --picture …/pxe-menu-1024x768.png --left 80 --right 80 --top 180 --bottom 60 || console`,
then the existing `menu` / `item` / `choose` block — text now lands
inside the framed window.
- Compile iPXE with `CONSOLE_FRAMEBUFFER`, `IMAGE_PNG`, `CONSOLE_CMD`,
`CONSOLE_VESAFB` (BIOS) and `CONSOLE_EFIFB` (UEFI). The v0.4.61 image
attempted this in-Docker via QEMU emulation and hit `cc1` segfaults.
The follow-up will use a Gitea Actions runner pinned to native
`linux/amd64` (an Unraid host already exists for this).
- Stretch goal: a second "theme pack" that ships a layered PNG with
subtle scanlines / grid — iPXE can't animate, but a well-designed
static composite beats iVentoy's plain centered logo handily.
## Source URLs
- https://github.com/ventoy/PXE
- https://github.com/ventoy/PXE/tree/master/iPXE
- https://github.com/ventoy/PXE/issues/11 — 1024x768 default
- https://github.com/ventoy/PXE/issues/59 — iVentoy iPXE EFI loader
- https://ipxe.org/cmd/console — `--picture` and compile flags
- https://github.com/ipxe/ipxe/discussions/945 — background image how-to
- https://github.com/ipxe/ipxe/discussions/802 — `CONSOLE_FRAMEBUFFER`
requirement
- https://github.com/ipxe/ipxe/discussions/1006 — picture resolution
behaviour
- https://www.iventoy.com/en/doc_edition.html — background / title not
user-customisable
- https://kingtam.win/archives/iventoy.html — third-party iPXE-based
iVentoy alternative
@@ -0,0 +1,199 @@
# OpenPXE v0.5.1 — SAML SSO wiring + Settings/Storage UI consolidation
**Date:** 2026-05-31
**Author:** Miles Ward (with Claude)
**Status:** Approved design → implementation
## Summary
Three workstreams for v0.5.1:
1. **Wire SAML 2.0 SSO** end-to-end (currently config is persisted but no runtime
sign-in exists). Pure-Rust implementation that preserves the static-musl /
no-OpenSSL architecture, mirroring how FleetDM exposes and handles SAML.
2. **Fold the Advanced sidebar tab into Settings** as a collapsible section.
3. **Merge the Storage tab's SMB and NFS cards** into one "Remote shares" card
with a protocol dropdown.
Then bump `0.5.0 → 0.5.1`, build the static musl image, push `:0.5.1` + `:latest`
to Gitea, create the release, and scrub registry credentials.
## Decisions (locked with the user)
- **Crypto:** pure-Rust via `bergshamra` (XML-DSig + exclusive c14n, RustCrypto-based,
`#![forbid(unsafe_code)]`, ~99% xmlsec interop). `samael` is rejected — it
hard-requires OpenSSL/`xmlsec`/`libxml2` C deps, which would break the static
musl binary and the project's pure-Rust / no-OpenSSL architecture.
- **Access model:** any SAML assertion the IdP successfully authenticates and that
we cryptographically verify mints a full operator session. No user table, no
roles, no domain allowlist. The local admin account remains a guaranteed
fallback owner regardless of SSO state.
- **Flows:** SP-initiated (the "Sign in with <IdP>" button) is always on.
IdP-initiated is supported but gated behind an `allow_idp_initiated` toggle
(default off), mirroring FleetDM's "Allow SSO login initiated by identity
provider."
## Scope boundaries (v0.5.1)
In scope: SP-initiated + (gated) IdP-initiated login, signature verification on the
SAML Response/Assertion, full SP-side semantic validation, SP metadata endpoint,
login-page button wiring.
Out of scope (note for later releases): EncryptedAssertion (assertions must be
unencrypted), signed AuthnRequests (sent unsigned; Keycloak "client signature
required" must be off), Single Logout (SLO), multi-user accounts / RBAC / JIT role
mapping.
---
## Workstream 1 — SAML SP wiring (pure-Rust)
### New dependencies (workspace)
- `bergshamra` — XML-DSig verification + exclusive c14n (pure Rust).
- `roxmltree` (read/navigate) and/or `quick-xml` (build/serialize) — parse IdP
metadata + SAMLResponse, build AuthnRequest and SP metadata.
- `x509-parser` — extract the IdP signing certificate / public key from metadata.
- `flate2` — raw DEFLATE for the HTTP-Redirect binding.
- `base64` — encode/decode SAMLRequest/SAMLResponse.
All pure-Rust → the `x86_64-unknown-linux-musl` static build stays OpenSSL-free.
Exact `bergshamra` function signatures (`verify`, `DsigContext`, `KeysManager`,
`Key`, `VerifiedReference`, `VerifyResult`) will be pinned against the installed
crate source during implementation.
### Module boundaries
Pure protocol logic lives in `openpxe-core` (no axum dependency, unit-testable);
HTTP wiring lives in `openpxe-http-api`.
- `crates/core/src/saml/mod.rs` — public surface + shared types
(`VerifiedPrincipal { email, display_name, name_id, session_index }`, `SamlError`).
- `crates/core/src/saml/metadata.rs` — parse IdP `EntityDescriptor`: IdP EntityID,
`SingleSignOnService` locations + bindings, and one or more X.509 signing
certificates. Also build **our** SP metadata XML.
- `crates/core/src/saml/authn_request.rs` — build an AuthnRequest, return both the
request ID (to track) and the encoded HTTP-Redirect query value
(deflate → base64 → URL-encode).
- `crates/core/src/saml/response.rs` — decode `SAMLResponse` (base64 → XML),
**verify the signature via bergshamra** against the IdP cert, then enforce SP
semantics, returning `VerifiedPrincipal` or a typed `SamlError`.
### SP-side validation (response.rs)
After a cryptographically valid signature over the Response and/or the Assertion:
1. `Status` is `Success`.
2. `Destination` (if present) equals our ACS URL.
3. `Conditions/AudienceRestriction/Audience` equals our SP EntityID.
4. `NotBefore` / `NotOnOrAfter` within bounds (allow small clock skew, e.g. ±60s).
5. `InResponseTo` matches an outstanding request we issued (SP-initiated). Absent
for IdP-initiated, which is only accepted when `allow_idp_initiated` is true.
6. Assertion-ID replay guard: reject a previously consumed assertion ID.
7. NameID is the email (`nameid-format:emailAddress`). Display name read from
common attributes (`name`, `displayname`, `cn`, `urn:oid:2.5.4.3`).
XML Signature Wrapping (XSW) defenses come from bergshamra (duplicate-ID rejection,
strict positional verification); enable its strict verification options. We
additionally confirm the verified `Reference` covers the element we read claims from.
### State (in `openpxe-http-api`)
Two small TTL-pruned in-memory stores (parking_lot `Mutex<HashMap<...>>`):
- **Outstanding requests:** `request_id → issued_at`, TTL ≈ 5 min, for `InResponseTo`.
- **Consumed assertions:** `assertion_id → expires_at`, TTL = assertion validity,
for replay protection.
(In-memory is acceptable: a single-container app; a restart simply invalidates
in-flight logins.)
### Routes (all pre-auth; added to the public allowlist in the auth middleware)
- `GET /api/sso/login` → build AuthnRequest, record its ID, 302 to the IdP SSO URL
(HTTP-Redirect binding) with `SAMLRequest` + `RelayState`.
- `POST /api/sso/acs` → consume `SAMLResponse` (form-encoded). Verify + validate.
On success: `SessionStore::create(email)`, set the `openpxe_session` cookie
(same attributes as forms login), 302 to the dashboard. On failure: 302 back to
the login page with an error indicator. (Mirrors FleetDM's `/sso/callback`.)
- `GET /api/sso/metadata` → serve our SP `EntityDescriptor` XML for IdP import.
### Config changes (`crates/core/src/sso.rs`)
Add to `SsoConfig` (preserve existing fields + validation):
- `entity_id: String` — SP Entity ID (mirrors FleetDM's "Entity ID"); defaults to
the configured public base URL. The ACS URL is derived as
`<public_base_url>/api/sso/acs`.
- `allow_idp_initiated: bool` — default `false`.
`GET /api/sso` returns the new fields; `PUT /api/sso` validates and persists them.
### Login page (`crates/webui/src/app.js`)
Replace the "configured · runtime pending" message: the existing
"Sign in with <IdP>" button navigates to `GET /api/sso/login`. Render the IdP logo
(if `idp_logo_url` set) and use `idp_name` as the label. Keep the existing
FleetDM-style login layout.
### Testing
- `core/saml` unit tests using a self-signed test keypair we control:
- Parse representative Keycloak IdP metadata → correct SSO URL + cert.
- Build an AuthnRequest → well-formed, deflate/base64 round-trips, ID recorded.
- A correctly signed Response → `VerifiedPrincipal { email, .. }`.
- Reject: tampered signature, expired (`NotOnOrAfter`), wrong audience,
replayed assertion ID, unsigned response, `Status != Success`.
- `http-api` integration test: `GET /api/sso/login` returns a 302 with a
`SAMLRequest` query param; a crafted signed `SAMLResponse` POSTed to
`/api/sso/acs` (signed with the test key) sets an `openpxe_session` cookie.
---
## Workstream 2 — Advanced tab → Settings
- Remove the `Advanced` sidebar entry (`crates/webui/src/index.html`) and its
`advanced` view route in `app.js`.
- In the Settings view, append a **collapsible "Advanced" disclosure**
(default-collapsed) at the bottom containing the existing **Webhook
Notifications** card and the **API reference** block (moved out of the removed
Advanced view).
- No backend changes; `/api/notify*` and `/api/docs` endpoints are unchanged.
---
## Workstream 3 — Storage: merge SMB + NFS → "Remote shares"
- Replace the separate "SMB shares" and "NFS shares" cards with a single
**"Remote shares"** card:
- One add-form with a **protocol dropdown (SMB / NFS)**. Selecting the protocol
swaps the fields: SMB → server, share, guest checkbox, username, password;
NFS → server, export path.
- One unified table with a leading **Protocol** column (SMB/NFS badge), then
server/share-or-export, auth, ISO count, reachability, and Re-scan / Remove
actions.
- **No backend changes.** The form dispatches to the existing
`POST /api/smb-shares` or `POST /api/nfs-shares`; the table merges
`GET /api/smb-shares` + `GET /api/nfs-shares`, tagging each row with its
protocol. Re-scan/Remove call the existing per-protocol endpoints.
- Leaves the card pattern open for a future "Config files" card.
---
## Release
1. Bump workspace version `0.5.0 → 0.5.1` (`Cargo.toml`).
2. `cargo fmt`, `cargo clippy`, `cargo test` (all crates) green.
3. Build the static musl binary + Docker image; verify SAML deps compile clean
under musl (no OpenSSL/C linkage).
4. Push `openpxe:0.5.1` + `openpxe:latest` to Gitea via the established
temp-DOCKER_CONFIG pipeline; scrub credentials (logout + verify no token traces).
5. Create the Gitea release `v0.5.1` with notes.
## Risks
- `bergshamra` is pre-1.0 and unaudited. Mitigation: pin the version, enable strict
verification, keep the local-admin fallback, and own the SP-semantic checks
carefully (audience/Conditions/replay/InResponseTo — where SP vulns usually live).
- SAML is security-sensitive; negative tests (tamper/expiry/audience/replay/unsigned)
are part of the definition of done, not optional.
+403
View File
@@ -0,0 +1,403 @@
# Runbook: Boot a Linux machine from an ISO over the network
End-to-end walkthrough: spin up OpenPXE, load an Ubuntu (or any
Linux) ISO into it, target a specific bare-metal or VM client by its
MAC address, and have that machine PXE-boot the installer over the
LAN — no USB stick, no console babysitting.
This runbook assumes:
- You have **one Linux host** to run the OpenPXE container (any
distro with Docker / Podman; 2 GB RAM, ~50 GB disk for the ISO
library).
- That host sits on the **same broadcast domain / VLAN** as the
client you want to boot. PXE is L2-broadcast — routed/VLANd
networks need a DHCP relay and are out of scope here.
- An **existing DHCP server** is already handing out IP leases on
that VLAN (your home router, OPNsense, Windows Server, etc.).
OpenPXE runs as a *DHCP proxy* — it never leases IPs, it only
layers the boot information on top of the existing DHCP exchange.
- The target client is configured to **PXE-boot** in BIOS/UEFI
firmware (usually `F12` boot menu → Network, or set as first boot
device).
If those dont hold, stop and read [troubleshooting.md](troubleshooting.md)
or [docs/architecture.md](../docs/architecture.md) first.
---
## 0. Pick your hosts LAN IP
You need the IPv4 address OpenPXE will advertise to clients. From
the host:
```bash
ip -4 -o addr show | awk '{print $2, $4}'
```
Pick the address on the interface that faces the PXE VLAN — for
example `10.0.0.5/24` on `eno1`. From here on we call it
`PXE_HOST_IP`.
> **Why this matters.** Every URL handed to clients (TFTP server,
> iPXE chain URL, ISO URL) is built from this IP. If OpenPXE
> auto-detects the wrong interface or loopback, clients will fetch
> from an unreachable address and silently fail. The startup will
> *fail loudly* if it can only auto-detect a loopback address.
---
## 1. Run OpenPXE
The MVP path is a single `docker run` against the published image,
with `--network host` so the container can see DHCP broadcasts on
the LAN.
```bash
mkdir -p ~/openpxe/isos ~/openpxe/work
docker run -d --name openpxe \
--restart unless-stopped \
--network host \
-e OPENPXE_PUBLIC_IP=10.0.0.5 \
-e OPENPXE_DHCP_MODE=proxy \
-v ~/openpxe/isos:/var/lib/openpxe/isos \
-v ~/openpxe/work:/var/lib/openpxe/work \
ghcr.io/YOUR-ORG/openpxe:0.2.0
```
Substitute your `OPENPXE_PUBLIC_IP`, of course. If youre building
from this repo instead of pulling, see the
[README quick start](../README.md#quick-start--mvp-container-recommended).
### Verify its alive
```bash
curl -fsS http://10.0.0.5/healthz # → 200 ok
curl -fsS http://10.0.0.5/readyz # → 200 ready (iPXE binaries present)
curl -fsS http://10.0.0.5/api/status | jq .
```
If `/readyz` is **not** 200, your container is missing iPXE binaries.
Fix that before going further — clients have nothing to boot
otherwise. See [README — Container health probes](../README.md#container-health-probes).
### Check the listening ports
OpenPXE holds three privileged UDP/TCP ports. From another shell on
the host:
```bash
sudo ss -lnup | grep -E ':(67|69|4011)\b' # DHCP proxy + TFTP
sudo ss -lntp | grep ':80\b' # HTTP UI / boot scripts
```
All four should be present. If port 67 is taken by `dnsmasq` or the
hosts own DHCP, stop that service or run OpenPXE on a separate box —
two listeners on `:67` will fight.
---
## 2. Load the ISO
Two options. Pick one.
### 2a. Web UI upload (recommended for one-offs)
1. Open `http://10.0.0.5/` in a browser.
2. Sidebar → **Storage**.
3. Click **Upload ISO**, pick e.g. `ubuntu-24.04.1-live-server-amd64.iso`.
4. Wait for upload + introspection. The row turns into a card showing:
- Distro family (`debian_ubuntu`)
- Volume label
- Detected kernel/initrd paths (`/casper/vmlinuz`, `/casper/initrd`)
- File size and SHA-256
Big ISOs stream — there is no 2 GB limit, but expect upload to be
throttled by your browser ↔ host link. The UI shows a progress bar; the
animated OpenPXE mark on the Dashboard tab fires up while imaging is in
flight.
### 2b. Bulk seed from a directory (recommended for fresh deploys / CI)
If you already have a folder of ISOs on the host, skip the browser:
```bash
# Dry run first — see what would be imported, no writes:
docker exec openpxe openpxe seed \
--from /seed \
--dry-run
# For real, mount the source dir read-only into the container:
docker run --rm \
-v /my/iso-library:/seed:ro \
-v ~/openpxe/isos:/var/lib/openpxe/isos \
-v ~/openpxe/work:/var/lib/openpxe/work \
-e OPENPXE_PUBLIC_IP=10.0.0.5 \
ghcr.io/YOUR-ORG/openpxe:0.2.0 seed --from /seed
```
Each `*.iso` in `/seed` runs through the same upload pipeline as the
web UI: copy → introspection → boot-entry generation → metadata
sidecar. Re-running is idempotent.
### Confirm the ISO is registered
```bash
curl -fsS http://10.0.0.5/api/isos | jq '.[] | {id, name, family, size}'
```
You should see something like:
```json
{
"id": "ubuntu-24-04-1-live-server-amd64",
"name": "ubuntu-24.04.1-live-server-amd64.iso",
"family": "debian_ubuntu",
"size": 2748000000
}
```
The `id` is the **slug**. Remember it — youll bind a MAC to it in
the next step.
---
## 3. Find the target machines MAC address
You need the MAC of the **NIC that will PXE**, not the OSs
loopback or wifi.
### 3a. From the target itself (if its already running an OS)
```bash
ip -o link | awk '/ether/ {print $2, $17}' # Linux
```
Pick the line for the wired NIC plugged into the PXE VLAN.
### 3b. From the firmware (if its a fresh box)
Most BIOS/UEFI screens display the NIC MAC during the network-boot
attempt — usually as `MAC: AA-BB-CC-DD-EE-FF` flashing on the splash
right before "PXE-E53: No boot filename received". Write it down.
### 3c. By letting it boot once and watching OpenPXE
Easiest if the box is in front of you:
1. Power on, hit `F12`, pick **Network boot**.
2. Without any binding configured, the client will land on the
OpenPXE menu (Default / Installers / Tools / Queued Deployment).
3. Dont pick anything. On your laptop:
```bash
curl -fsS http://10.0.0.5/api/clients | jq .
```
4. The most-recent entry is your target. Copy its `mac`.
From here on we call this MAC `TARGET_MAC` (e.g. `aa:bb:cc:dd:ee:ff`).
Hyphens vs colons, upper vs lower case — OpenPXE normalizes both.
---
## 4. Pin that machine to the Ubuntu ISO
This is the **per-MAC host binding**. With it set, the client wont
see the menu at all — it goes straight to the bound boot entry,
Tinkerbell-style.
### 4a. Via the web UI
1. Sidebar → **Hosts**.
2. **Add binding**:
- **MAC**: `aa:bb:cc:dd:ee:ff`
- **Target**: pick `ubuntu-24-04-1-live-server-amd64` from the dropdown.
- **Label**: free-form, e.g. `lab-rack3-node07`.
3. Save.
### 4b. Via the API
```bash
curl -fsS -X POST http://10.0.0.5/api/hosts \
-H 'content-type: application/json' \
-d '{
"mac": "aa:bb:cc:dd:ee:ff",
"target": "ubuntu-24-04-1-live-server-amd64",
"label": "lab-rack3-node07"
}' | jq .
```
The binding is persisted to `~/openpxe/work/hosts.json` and survives
container restart.
### Confirm
```bash
curl -fsS http://10.0.0.5/api/hosts | jq '.[] | select(.mac=="aa:bb:cc:dd:ee:ff")'
```
You should see your entry with `created_at` and `updated_at`
timestamps.
---
## 5. Trigger the network boot on the target
Now actually boot the machine.
### 5a. Boot order
In firmware setup, set the wired NIC as the **first** boot device
(or hold `F12` / `F9` / `Esc` — vendor-specific — to pick "Network
Boot" interactively).
### 5b. What you should see on the target screen
In order, with timing:
| Stage | Approximate duration | What appears |
|-------|---------------------:|--------------|
| Firmware DHCPDISCOVER | ~1 s | `Start PXE over IPv4` / `Station IP address …` |
| TFTP iPXE binary fetch | ~1 s | `TFTP… snponly.efi` (or `undionly.kpxe` for legacy BIOS) |
| iPXE banner | ~1 s | The blue iPXE splash, version string |
| iPXE second-stage DHCP | ~1 s | `Configuring (net0 …)` then `ok` |
| HTTP boot script fetch | <1 s | `http://10.0.0.5/boot.ipxe?mac=…` |
| Per-MAC chain | <1 s | `OpenPXE: per-MAC binding -> ubuntu-24-04-1-…` |
| Kernel + initrd HTTP | 530 s | Two 200-OK fetches against `/iso/<id>/casper/vmlinuz` and `…/initrd` |
| Kernel boot | 510 s | Kernel banner, then the Ubuntu/cloud-init splash |
| Installer comes up | 3060 s | The distros normal Live/installer environment |
If everything works, youre looking at the Ubuntu Server installer
welcome screen end-to-end **without ever touching a USB stick**.
### 5c. Watch it from the server
In a third shell, tail the live log:
```bash
curl -N http://10.0.0.5/api/log/stream
```
Youll see each protocol step as it happens:
```
INFO openpxe::dhcp: reply mac=aa:bb:cc:dd:ee:ff arch=X8664Uefi target=tftp/snponly.efi
INFO openpxe::tftp: RRQ snponly.efi blksize=1468 windowsize=8 → 982 KiB in 412 ms
INFO openpxe::dhcp: reply mac=aa:bb:cc:dd:ee:ff (iPXE) target=http/boot.ipxe
INFO openpxe::http: GET /boot.ipxe?mac=aa:bb:cc:dd:ee:ff → host binding hit
INFO openpxe::http: GET /iso/ubuntu-…/casper/vmlinuz Range=bytes=0- 200 OK 14 MiB
INFO openpxe::http: GET /iso/ubuntu-…/casper/initrd Range=bytes=0- 200 OK 75 MiB
```
The **Terminal** tab in the web UI shows the same thing live, plus a
short whitelisted command palette (`status`, `clients`, `queue`,
`hosts`, `log`).
### 5d. Internet-side ISO sources
The runbook title says “via the internet” — the **client** itself
boots from your LAN, but the underlying ISO can come from anywhere
your *host* can reach:
- **Direct upload** from a remote workstation via the web UI (HTTPS
reverse-proxied if you put OpenPXE behind nginx/Caddy).
- **NFS mount** of a remote share — Sidebar → **Storage****NFS**
`nfs://files.lab.example.com/exports/isos`. Mounted ISOs show up in
the same list and are PXE-bootable directly without copying.
- **Pre-seed** from a CI job that `curl`s a vendor mirror and runs
`openpxe seed --from`.
OpenPXE itself never reaches out to the internet at boot time — all
client traffic stays on the LAN, served from the host.
---
## 6. After the install
Once Ubuntu has finished installing to the targets disk, you want
the next reboot to come up off the new local disk, **not** PXE
again. Two ways:
### 6a. One-shot — release the binding
```bash
curl -fsS -X DELETE http://10.0.0.5/api/hosts/aa:bb:cc:dd:ee:ff
```
Without a binding, the client either gets the menu (BIOS still set
to PXE first) or boots local disk normally.
### 6b. Permanent — pin to local disk
Re-bind to the reserved local-boot target:
```bash
curl -fsS -X POST http://10.0.0.5/api/hosts \
-H 'content-type: application/json' \
-d '{ "mac": "aa:bb:cc:dd:ee:ff", "target": "_local", "label": "lab-rack3-node07 (installed)" }'
```
Now if anyone hits `F12 → Network` by accident, OpenPXE replies
with a script that says *"chain back to local HDD"* and the box
boots its real OS instead of re-imaging itself. This is the safest
default for production hardware.
---
## 7. Re-imaging — the “Queued Deployment” flow
Different scenario: you have **a rack of 30 servers** to image
identically, all at once. Dont bind 30 MACs by hand. Use the queue.
1. **Dont** create host bindings.
2. PXE-boot every machine. They land on the menu.
3. On each: select **Queued Deployment**. They get position #1, #2,
…, #30 and start long-polling.
4. In the UI: **Queue** tab shows all 30 lined up. Pick the
ISO, click **Assign to all waiting**.
5. Every clients open long-poll wakes up at the same instant and
chains the same boot script. They all start imaging
simultaneously.
The animated OpenPXE progress widget on the Dashboard runs while any client is
still in the kernel-fetch phase.
---
## Cheat sheet
| Goal | Command |
|------|---------|
| Health check | `curl http://$IP/healthz` |
| List ISOs | `curl http://$IP/api/isos \| jq .` |
| List clients seen | `curl http://$IP/api/clients \| jq .` |
| Bind MAC → ISO | `POST /api/hosts` with `{mac,target,label}` |
| Bind MAC → local disk | same with `target=_local` |
| Release binding | `DELETE /api/hosts/<mac>` |
| Live log | `curl -N http://$IP/api/log/stream` |
| Prometheus metrics | `curl http://$IP/metrics` |
| Bulk import folder | `openpxe seed --from /path` |
---
## Where to look when things break
- **Client gets `PXE-E53: No boot filename received`** — DHCP proxy
isnt replying. Check `:67` is bound (`ss -lnup`), check
`--network host`, check the host firewall on UDP 67/69/4011.
- **iPXE shows `No more network devices`** — firmware NIC isnt in
PXE mode, or VLAN tagging is wrong.
- **iPXE prints `Connection timed out (http://…)`**`OPENPXE_PUBLIC_IP`
is wrong. Clients cant reach that IP. Check `/api/status`
`public_base_url` and `ping` it from the client subnet.
- **Kernel panics during initrd load** — corrupt ISO upload. Check
`/api/isos`, compare the SHA-256 to the vendors, re-upload.
- **Boot menu shows but the bound entry doesnt fire** — the binding
target slug doesnt match any ISO `id`. Recheck
`GET /api/hosts` against `GET /api/isos`. The binding falls back
to the menu on miss (by design — never lock a client out).
- **General confusion** — Terminal tab → `status`, then `log`. That
tells you what protocol stages have run and which havent.
For deeper protocol-level debugging, see
[docs/architecture.md](../docs/architecture.md).
+9 -9
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# build-and-publish-unraid.sh — one-shot: clone PXEForge, build the image, # build-and-publish-unraid.sh — one-shot: clone OpenPXE, build the image,
# push it to your local Gitea container registry. Run this ON the Unraid # push it to your local Gitea container registry. Run this ON the Unraid
# box (or any host that can reach Gitea on http://localhost:3000 or its # box (or any host that can reach Gitea on http://localhost:3000 or its
# LAN IP). No Cloudflare in the way; the proxy doesn't matter for this # LAN IP). No Cloudflare in the way; the proxy doesn't matter for this
@@ -10,31 +10,31 @@
# GITEA_HOST default: localhost:3000 (use 192.168.1.49:3000 if # GITEA_HOST default: localhost:3000 (use 192.168.1.49:3000 if
# you're on the LAN but not on the Unraid host) # you're on the LAN but not on the Unraid host)
# GITEA_OWNER default: mward4 # GITEA_OWNER default: mward4
# GITEA_REPO default: PXEForge # GITEA_REPO default: OpenPXE
# IMAGE_TAG default: 0.1.0 (also tagged :latest) # IMAGE_TAG default: 0.1.0 (also tagged :latest)
# PLATFORM default: linux/amd64 (Unraid is x86_64) # PLATFORM default: linux/amd64 (Unraid is x86_64)
# WORKDIR default: /tmp/pxeforge-build (deleted on success) # WORKDIR default: /tmp/openpxe-build (deleted on success)
# #
# What it does: # What it does:
# 1. git clone <gitea>/mward4/PXEForge.git into WORKDIR # 1. git clone <gitea>/mward4/OpenPXE.git into WORKDIR
# 2. fetch iPXE binaries (scripts/fetch-ipxe.sh) # 2. fetch iPXE binaries (scripts/fetch-ipxe.sh)
# 3. docker build deploy/docker/Dockerfile -> pxeforge:$TAG (and :latest) # 3. docker build deploy/docker/Dockerfile -> openpxe:$TAG (and :latest)
# 4. docker login to GITEA_HOST using the token # 4. docker login to GITEA_HOST using the token
# 5. docker push to <gitea>/<owner>/pxeforge:<tag> and :latest # 5. docker push to <gitea>/<owner>/openpxe:<tag> and :latest
# 6. docker logout, scrub creds, clean WORKDIR # 6. docker logout, scrub creds, clean WORKDIR
# #
# After this, on any Unraid Docker template, set: # After this, on any Unraid Docker template, set:
# Repository: <gitea>/mward4/pxeforge:0.1.0 (or :latest) # Repository: <gitea>/mward4/openpxe:0.1.0 (or :latest)
# Network: host (DHCP/TFTP need raw L2) # Network: host (DHCP/TFTP need raw L2)
set -euo pipefail set -euo pipefail
GITEA_HOST=${GITEA_HOST:-localhost:3000} GITEA_HOST=${GITEA_HOST:-localhost:3000}
GITEA_OWNER=${GITEA_OWNER:-mward4} GITEA_OWNER=${GITEA_OWNER:-mward4}
GITEA_REPO=${GITEA_REPO:-PXEForge} GITEA_REPO=${GITEA_REPO:-OpenPXE}
IMAGE_TAG=${IMAGE_TAG:-0.1.0} IMAGE_TAG=${IMAGE_TAG:-0.1.0}
PLATFORM=${PLATFORM:-linux/amd64} PLATFORM=${PLATFORM:-linux/amd64}
WORKDIR=${WORKDIR:-/tmp/pxeforge-build} WORKDIR=${WORKDIR:-/tmp/openpxe-build}
# Lowercase the image name — OCI distribution rejects uppercase paths. # Lowercase the image name — OCI distribution rejects uppercase paths.
IMAGE_NAME="$(printf '%s' "$GITEA_REPO" | tr '[:upper:]' '[:lower:]')" IMAGE_NAME="$(printf '%s' "$GITEA_REPO" | tr '[:upper:]' '[:lower:]')"
+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
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Fetch prebuilt iPXE binaries from the official distribution at # Fetch prebuilt iPXE binaries from the official distribution at
# https://boot.ipxe.org/ and place them under assets/ipxe/ with the filenames # https://boot.ipxe.org/ and place them under assets/ipxe/ with the filenames
# PXEForge's arch mapping expects. # OpenPXE's arch mapping expects.
# #
# Why not build from source? # Why not build from source?
# - Building iPXE requires the toolchain + several megabytes of source, and # - Building iPXE requires the toolchain + several megabytes of source, and