Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 2. NFS AUTH_SYS credential — fixes NFS3ERR_ACCES

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

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

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

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

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

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

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

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

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

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

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

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

## 3. Disk-space card on the Dashboard

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

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

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

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

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

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

## What's new

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

## What's the same

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

## Why nfs3_client

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

## Tests

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

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

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

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

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

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

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

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

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

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

What's gone:

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

What's new:

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

Why subprocess and not a Rust crate:

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

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

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

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

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

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

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

What's new:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Backend

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

## HTTP API

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

## iPXE prompt

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

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

## UI

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

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

## Menu indicator

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

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

## Tests

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

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

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

## Operational notes

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

## Naming changes

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

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

## Brand assets

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

## CSS rename

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

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

## Validation

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

## Migration notes for operators on v0.2.0

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

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

## New features

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

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

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

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

## Code cleanup pass

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

## Windows workflow validation

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

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

## Docs

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

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

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

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

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

## Phase 1 — protocol stack

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

## Phase 2 — UX + Windows

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

## Phase 3 — MVP hardening

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

## Phase 4 — UI restructure + remote storage

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

## Tests

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

cargo build --workspace and cargo clippy --workspace --all-targets
both finish clean (warnings only, no errors).
2026-04-29 02:47:00 -04:00
21 changed files with 377 additions and 2342 deletions
+16
View File
@@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"Bash(cargo check *)",
"Bash(cargo build *)",
"Bash(cargo clippy *)",
"Bash(cargo fmt *)",
"Bash(cargo tree *)",
"Bash(cargo doc *)",
"Bash(cargo test --workspace --lib)",
"Bash(cargo test --workspace)",
"Bash(cargo --version)",
"Bash(rustc --version)"
]
}
}
Generated
+8 -8
View File
@@ -2251,7 +2251,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "openpxe" name = "openpxe"
version = "0.5.3" version = "0.5.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -2273,7 +2273,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-core" name = "openpxe-core"
version = "0.5.3" version = "0.5.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -2299,7 +2299,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-dhcp-proxy" name = "openpxe-dhcp-proxy"
version = "0.5.3" version = "0.5.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -2313,7 +2313,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-http-api" name = "openpxe-http-api"
version = "0.5.3" version = "0.5.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -2349,7 +2349,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-ipxe-assets" name = "openpxe-ipxe-assets"
version = "0.5.3" version = "0.5.1"
dependencies = [ dependencies = [
"openpxe-core", "openpxe-core",
"rust-embed", "rust-embed",
@@ -2359,7 +2359,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-iso-store" name = "openpxe-iso-store"
version = "0.5.3" version = "0.5.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bcrypt", "bcrypt",
@@ -2386,7 +2386,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-tftp" name = "openpxe-tftp"
version = "0.5.3" version = "0.5.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -2400,7 +2400,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-webui" name = "openpxe-webui"
version = "0.5.3" version = "0.5.1"
[[package]] [[package]]
name = "p256" name = "p256"
+1 -1
View File
@@ -12,7 +12,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.5.3" version = "0.5.1"
edition = "2021" edition = "2021"
rust-version = "1.95" rust-version = "1.95"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+145 -435
View File
@@ -1,23 +1,11 @@
//! Operator-controlled branding overrides. //! Operator-controlled branding overrides.
//! //!
//! v0.5.2 splits the single brand mark into **three independent slots**, //! The browser tab's logo (`/assets/logo.svg`) defaults to the bundled
//! FleetDM-style: //! rainbow-horizon mark. Operators who deploy OpenPXE behind their own
//! //! branding can upload a replacement that lives at
//! * `light` — shown in the WebUI top-left and on the form-login page //! `<work_dir>/branding/logo.<ext>` and is served in preference to the
//! when the active theme is light. //! bundled SVG when present. Borrowed-from-FleetDM: tenant chrome, same
//! * `dark` — same surfaces, when the active theme is dark. //! product.
//! * `client` — the raster painted above the iPXE boot menu entries
//! (`/branding/pxe-logo`), i.e. what a PXE client sees on the screen.
//!
//! Each slot lives at `<work_dir>/branding/logo-<slot>.<ext>` and is
//! served in preference to the bundled rainbow-horizon mark when present.
//! Borrowed-from-FleetDM: tenant chrome, same product.
//!
//! Legacy continuity: a pre-v0.5.2 single `logo.<ext>` (recorded under
//! the old `logo_filename`/`logo_mime` keys) is migrated on first load
//! into both the `dark` and `client` slots — that preserves the previous
//! behaviour (one mark fed both the dark WebUI and the PXE screen) until
//! the operator uploads dedicated variants.
//! //!
//! Storage policy mirrors `HostBindings` / `BootLog`: in-memory cache is //! Storage policy mirrors `HostBindings` / `BootLog`: in-memory cache is
//! authoritative for the current process, disk is the source of truth on //! authoritative for the current process, disk is the source of truth on
@@ -47,104 +35,27 @@ pub const ALLOWED_LOGO_MIMES: &[&str] = &[
/// puts a clear bound on memory + serialization cost. /// puts a clear bound on memory + serialization cost.
pub const MAX_LOGO_BYTES: usize = 2 * 1024 * 1024; pub const MAX_LOGO_BYTES: usize = 2 * 1024 * 1024;
/// Which branded surface a logo upload targets.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogoSlot {
/// WebUI + form-login page, light theme.
Light,
/// WebUI + form-login page, dark theme.
Dark,
/// iPXE boot-menu background seen by PXE clients.
Client,
}
impl LogoSlot {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
LogoSlot::Light => "light",
LogoSlot::Dark => "dark",
LogoSlot::Client => "client",
}
}
/// Parse a slot name from the URL path segment. Case-insensitive.
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"light" => Some(LogoSlot::Light),
"dark" => Some(LogoSlot::Dark),
"client" => Some(LogoSlot::Client),
_ => None,
}
}
}
/// One brand-mark slot: a filename (relative to the branding dir) plus
/// the MIME we cached at upload time so the HTTP layer can set the
/// Content-Type without re-sniffing.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Slot {
#[serde(default, skip_serializing_if = "Option::is_none")]
filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
mime: Option<String>,
}
impl Slot {
fn clear_file(&mut self, dir: &Path) {
if let Some(name) = self.filename.take() {
let _ = std::fs::remove_file(dir.join(name));
}
self.mime = None;
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Inner { struct Inner {
#[serde(default)] /// File name (relative to the branding dir) for the active logo, if
light: Slot, /// any. Always under `<work_dir>/branding/`; never an absolute path
#[serde(default)] /// from the operator.
dark: Slot, logo_filename: Option<String>,
#[serde(default)] /// MIME of the active logo, mirroring `logo_filename`. Cached here
client: Slot, /// so the HTTP layer can set Content-Type without re-sniffing.
/// Monotonic counter bumped on every set/clear (any slot). Surfaces logo_mime: Option<String>,
/// as a cache-bust token (`/assets/logo.svg?r=<rev>`) so the browser /// Monotonic counter bumped on every set/clear. Surfaces as a
/// fetches the new bytes the moment the operator swaps a logo — the /// cache-bust token (`/assets/logo.svg?r=<rev>`) so the browser
/// app version alone can't do this since it doesn't change on upload. /// fetches the new bytes the moment the operator swaps the logo —
/// Persisted so the token stays stable across restarts and keeps /// the app version alone can't do this since it doesn't change on
/// climbing across multiple swaps. /// upload. Persisted so the token stays stable across restarts and
/// keeps climbing across multiple swaps.
#[serde(default)] #[serde(default)]
rev: u64, 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 /// In-memory + on-disk override registry. Cheap to clone; locks are
/// brief. The `branding.json` cache lives alongside the active assets /// brief. The `branding.json` cache lives alongside the active asset
/// inside `<work_dir>/branding/`. /// inside `<work_dir>/branding/`.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct BrandingStore { pub struct BrandingStore {
@@ -156,8 +67,7 @@ pub struct BrandingStore {
impl BrandingStore { impl BrandingStore {
/// Load (or initialise empty) from `<work_dir>/branding/`. Tolerates /// Load (or initialise empty) from `<work_dir>/branding/`. Tolerates
/// missing directories, partial state, and corrupt JSON — a bad /// missing directories, partial state, and corrupt JSON — a bad
/// cache should never block PXE for the network. Migrates a legacy /// cache should never block PXE for the network.
/// single-logo file into the dark + client slots.
#[must_use] #[must_use]
pub fn load_or_default(work_dir: &Path) -> Self { pub fn load_or_default(work_dir: &Path) -> Self {
let dir = work_dir.join("branding"); let dir = work_dir.join("branding");
@@ -165,7 +75,25 @@ impl BrandingStore {
let mut inner = Inner::default(); let mut inner = Inner::default();
if let Ok(text) = std::fs::read_to_string(&path) { if let Ok(text) = std::fs::read_to_string(&path) {
match serde_json::from_str::<Inner>(&text) { match serde_json::from_str::<Inner>(&text) {
Ok(parsed) => inner = parsed, Ok(parsed) => {
// Sanity: if the JSON says we have a logo but the
// file is gone, clear the in-memory pointer so
// /assets/logo.svg falls back to the bundled SVG
// rather than 500ing on a missing file.
if let Some(name) = parsed.logo_filename.as_deref() {
if dir.join(name).is_file() {
inner = parsed;
} else {
tracing::warn!(
target: "openpxe::branding",
file = %name,
"branding.json points at missing file; clearing"
);
}
} else {
inner = parsed;
}
}
Err(e) => { Err(e) => {
tracing::warn!( tracing::warn!(
target: "openpxe::branding", target: "openpxe::branding",
@@ -174,242 +102,102 @@ impl BrandingStore {
} }
} }
} }
let store = Self { Self {
dir: Arc::new(dir), dir: Arc::new(dir),
inner: Arc::new(RwLock::new(inner)), inner: Arc::new(RwLock::new(inner)),
}; }
store.migrate_legacy();
store.prune_missing();
store
} }
/// One-way migration: a pre-v0.5.2 `logo.<ext>` becomes the dark + /// Absolute path to the active logo, if one is set and present on
/// client slots (the old single mark fed both the dark WebUI and the /// disk. `None` means the HTTP layer should serve the bundled SVG.
/// PXE screen). Best-effort; failures leave the legacy file in place #[must_use]
/// rather than blocking startup. pub fn logo_path(&self) -> Option<PathBuf> {
fn migrate_legacy(&self) { let g = self.inner.read();
let (legacy_name, legacy_mime) = { g.logo_filename.as_deref().map(|n| self.dir.join(n))
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 /// MIME of the active logo, if any. The HTTP layer pairs this with
/// so the HTTP layer falls back to the bundled mark instead of 500ing. /// the bytes returned by [`Self::logo_path`].
fn prune_missing(&self) { #[must_use]
let mut changed = false; pub fn logo_mime(&self) -> Option<String> {
{ self.inner.read().logo_mime.clone()
let mut g = self.inner.write(); }
for slot in [LogoSlot::Light, LogoSlot::Dark, LogoSlot::Client] {
let present = g /// Replace the active logo. Returns the chosen on-disk filename so
.slot(slot) /// the caller can echo it back in the API response. Old logos are
.filename /// removed best-effort.
.as_deref() pub fn set_logo(&self, mime: &str, ext: &str, bytes: &[u8]) -> std::io::Result<String> {
.is_some_and(|n| self.dir.join(n).is_file()); std::fs::create_dir_all(self.dir.as_path())?;
if !present && g.slot(slot).filename.is_some() { // Single canonical filename per upload — overwriting the old one
g.slot_mut(slot).filename = None; // (after clearing it) keeps the directory tidy and avoids any
g.slot_mut(slot).mime = None; // path-traversal concern: the operator never supplies the name.
changed = true; let safe_ext = sanitize_ext(ext);
let filename = format!("logo.{safe_ext}");
let final_path = self.dir.join(&filename);
// Atomic write: tmp -> rename. Guarantees the file is either
// entirely the old logo or entirely the new one.
let tmp = final_path.with_extension(format!("{safe_ext}.tmp"));
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, &final_path)?;
// Clean up any sibling logo.<otherext> so there's exactly one
// canonical file at any time.
if let Ok(entries) = std::fs::read_dir(self.dir.as_path()) {
for e in entries.flatten() {
let p = e.path();
let name = p
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
if name.starts_with("logo.") && name != filename {
let _ = std::fs::remove_file(&p);
} }
} }
} }
if changed {
self.persist(); {
let mut g = self.inner.write();
g.logo_filename = Some(filename.clone());
g.logo_mime = Some(mime.to_string());
g.rev = g.rev.wrapping_add(1);
} }
}
/// 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(); self.persist();
tracing::info!( tracing::info!(
target: "openpxe::branding", target: "openpxe::branding",
slot = slot.as_str(), file = %filename, mime = %mime, size = bytes.len(), file = %filename, mime = %mime, size = bytes.len(),
"custom logo installed" "custom logo installed"
); );
Ok(filename) Ok(filename)
} }
/// Write the bytes for a slot and update the in-memory pointer + rev, /// Drop the override and return to the bundled SVG.
/// without persisting (the caller decides when to flush). Cleans up pub fn clear_logo(&self) -> std::io::Result<()> {
/// any sibling `logo-<slot>.*` so there's exactly one file per slot. let removed = {
fn write_slot(
&self,
slot: LogoSlot,
mime: &str,
ext: &str,
bytes: &[u8],
) -> std::io::Result<String> {
std::fs::create_dir_all(self.dir.as_path())?;
let safe_ext = sanitize_ext(ext);
let stem = format!("logo-{}", slot.as_str());
let filename = format!("{stem}.{safe_ext}");
let final_path = self.dir.join(&filename);
// Atomic write: tmp -> rename.
let tmp = final_path.with_extension(format!("{safe_ext}.tmp"));
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, &final_path)?;
// Clean up any sibling `logo-<slot>.<otherext>`.
if let Ok(entries) = std::fs::read_dir(self.dir.as_path()) {
for e in entries.flatten() {
let p = e.path();
let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
if name.starts_with(&format!("{stem}.")) && name != filename {
let _ = std::fs::remove_file(&p);
}
}
}
let mut g = self.inner.write();
let s = g.slot_mut(slot);
s.filename = Some(filename.clone());
s.mime = Some(mime.to_string());
g.rev = g.rev.wrapping_add(1);
Ok(filename)
}
/// Drop the override for `slot` and return to the bundled / default.
pub fn clear_logo(&self, slot: LogoSlot) -> std::io::Result<()> {
{
let mut g = self.inner.write(); let mut g = self.inner.write();
let dir = self.dir.as_path(); let removed = g.logo_filename.take();
g.slot_mut(slot).clear_file(dir); g.logo_mime = None;
g.rev = g.rev.wrapping_add(1); g.rev = g.rev.wrapping_add(1);
removed
};
if let Some(name) = removed {
let p = self.dir.join(&name);
let _ = std::fs::remove_file(&p);
tracing::info!(target: "openpxe::branding", file = %name, "custom logo cleared");
} }
self.persist(); self.persist();
tracing::info!(target: "openpxe::branding", slot = slot.as_str(), "custom logo cleared");
Ok(()) Ok(())
} }
/// True if a custom logo is configured for `slot`. /// Convenience: true if a custom logo is configured. Surfaces on
/// `/api/status` so the WebUI can show "Custom logo: yes" without
/// fetching the asset itself.
#[must_use] #[must_use]
pub fn has_logo(&self, slot: LogoSlot) -> bool { pub fn has_logo(&self) -> bool {
self.inner.read().slot(slot).filename.is_some() self.inner.read().logo_filename.is_some()
} }
/// True if either WebUI theme slot has a custom logo — drives the /// Cache-bust token for the logo asset URL. Changes on every
/// 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 /// set/clear so `/assets/logo.svg?r=<rev>` resolves to a fresh URL
/// whenever the operator swaps a brand mark. Stable otherwise. /// whenever the operator swaps the brand mark. Stable otherwise.
#[must_use] #[must_use]
pub fn logo_rev(&self) -> u64 { pub fn logo_rev(&self) -> u64 {
self.inner.read().rev self.inner.read().rev
@@ -479,87 +267,47 @@ mod tests {
fn empty_after_load_when_no_branding_dir() { fn empty_after_load_when_no_branding_dir() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path()); let b = BrandingStore::load_or_default(dir.path());
assert!(!b.has_logo(LogoSlot::Light)); assert!(!b.has_logo());
assert!(!b.has_logo(LogoSlot::Dark)); assert!(b.logo_path().is_none());
assert!(!b.has_logo(LogoSlot::Client)); assert!(b.logo_mime().is_none());
assert!(b.web_logo(false).is_none());
assert!(b.client_logo().is_none());
assert!(!b.has_any_web_logo());
} }
#[test] #[test]
fn set_clear_round_trip_persists() { fn set_clear_round_trip_persists() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path()); let b = BrandingStore::load_or_default(dir.path());
let name = b let name = b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake").unwrap();
.set_logo(LogoSlot::Dark, "image/png", "png", b"\x89PNG\r\n\x1a\nfake") assert_eq!(name, "logo.png");
.unwrap(); assert!(b.has_logo());
assert_eq!(name, "logo-dark.png"); assert_eq!(b.logo_mime().as_deref(), Some("image/png"));
assert!(b.has_logo(LogoSlot::Dark)); let p = b.logo_path().unwrap();
assert_eq!(b.slot_mime(LogoSlot::Dark).as_deref(), Some("image/png"));
let (p, _) = b.web_logo(false).unwrap();
assert!(p.is_file()); assert!(p.is_file());
// Re-open and confirm the override survives a restart. // Re-open and confirm the override survives a restart.
drop(b); drop(b);
let b2 = BrandingStore::load_or_default(dir.path()); let b2 = BrandingStore::load_or_default(dir.path());
assert!(b2.has_logo(LogoSlot::Dark)); assert!(b2.has_logo());
assert_eq!(b2.slot_mime(LogoSlot::Dark).as_deref(), Some("image/png")); assert_eq!(b2.logo_mime().as_deref(), Some("image/png"));
// Clear; the file goes away and has_logo flips off. // Clear; the file goes away and has_logo flips off.
b2.clear_logo(LogoSlot::Dark).unwrap(); b2.clear_logo().unwrap();
assert!(!b2.has_logo(LogoSlot::Dark)); assert!(!b2.has_logo());
assert!(!p.exists()); assert!(!p.exists());
} }
#[test] #[test]
fn web_logo_falls_back_across_themes() { fn replacing_logo_removes_old_extension_sibling() {
// PNG then SVG; only the SVG should remain on disk.
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path()); let b = BrandingStore::load_or_default(dir.path());
// Only dark uploaded — light theme falls back to it. b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake").unwrap();
b.set_logo(LogoSlot::Dark, "image/png", "png", b"dark") b.set_logo("image/svg+xml", "svg", br#"<svg xmlns="http://www.w3.org/2000/svg"/>"#).unwrap();
.unwrap();
let (p_light, _) = b.web_logo(true).expect("light falls back to dark");
assert!(p_light.ends_with("logo-dark.png"));
// Upload a distinct light — now light theme uses its own.
b.set_logo(LogoSlot::Light, "image/png", "png", b"light")
.unwrap();
let (p_light2, _) = b.web_logo(true).unwrap();
assert!(p_light2.ends_with("logo-light.png"));
// Client is independent and still unset.
assert!(b.client_logo().is_none());
}
#[test]
fn replacing_slot_removes_old_extension_sibling() {
let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path());
b.set_logo(
LogoSlot::Client,
"image/png",
"png",
b"\x89PNG\r\n\x1a\nfake",
)
.unwrap();
b.set_logo(
LogoSlot::Client,
"image/svg+xml",
"svg",
br#"<svg xmlns="http://www.w3.org/2000/svg"/>"#,
)
.unwrap();
let entries: Vec<_> = std::fs::read_dir(dir.path().join("branding")) let entries: Vec<_> = std::fs::read_dir(dir.path().join("branding"))
.unwrap() .unwrap()
.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned())) .filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned()))
.collect(); .collect();
assert!( assert!(entries.iter().any(|n| n == "logo.svg"), "got {entries:?}");
entries.iter().any(|n| n == "logo-client.svg"), assert!(!entries.iter().any(|n| n == "logo.png"), "stale PNG left over: {entries:?}");
"got {entries:?}"
);
assert!(
!entries.iter().any(|n| n == "logo-client.png"),
"stale PNG left over: {entries:?}"
);
} }
#[test] #[test]
@@ -567,92 +315,54 @@ mod tests {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path()); let b = BrandingStore::load_or_default(dir.path());
assert_eq!(b.logo_rev(), 0); assert_eq!(b.logo_rev(), 0);
b.set_logo(LogoSlot::Light, "image/png", "png", b"a") b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake").unwrap();
.unwrap();
assert_eq!(b.logo_rev(), 1); assert_eq!(b.logo_rev(), 1);
b.set_logo(LogoSlot::Dark, "image/png", "png", b"b") b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake2").unwrap();
.unwrap();
assert_eq!(b.logo_rev(), 2); assert_eq!(b.logo_rev(), 2);
b.clear_logo(LogoSlot::Light).unwrap(); b.clear_logo().unwrap();
assert_eq!(b.logo_rev(), 3); assert_eq!(b.logo_rev(), 3);
// Survives a restart.
drop(b); drop(b);
let b2 = BrandingStore::load_or_default(dir.path()); let b2 = BrandingStore::load_or_default(dir.path());
assert_eq!(b2.logo_rev(), 3); assert_eq!(b2.logo_rev(), 3);
} }
#[test]
fn legacy_single_logo_migrates_to_dark_and_client() {
// A pre-v0.5.2 branding.json + logo.png migrates on load.
let dir = tempdir().unwrap();
let brand_dir = dir.path().join("branding");
std::fs::create_dir_all(&brand_dir).unwrap();
std::fs::write(brand_dir.join("logo.png"), b"\x89PNG\r\n\x1a\nlegacy").unwrap();
// Hand-write the old shape (logo_filename/logo_mime, no slots).
std::fs::write(
brand_dir.join("branding.json"),
br#"{"logo_filename":"logo.png","logo_mime":"image/png","rev":4}"#,
)
.unwrap();
let b = BrandingStore::load_or_default(dir.path());
assert!(b.has_logo(LogoSlot::Dark), "dark seeded from legacy");
assert!(b.has_logo(LogoSlot::Client), "client seeded from legacy");
assert!(!b.has_logo(LogoSlot::Light), "light stays empty");
// The old logo.png is gone; per-slot files exist.
assert!(!brand_dir.join("logo.png").exists());
assert!(brand_dir.join("logo-dark.png").is_file());
assert!(brand_dir.join("logo-client.png").is_file());
// rev carried over from the legacy file and advanced as the two
// slots were seeded (each write bumps it), so it never regresses.
let migrated_rev = b.logo_rev();
assert!(
migrated_rev >= 4,
"rev should not regress below legacy: {migrated_rev}"
);
// And the migration is sticky across a restart (no re-migrate, no
// further rev churn).
drop(b);
let b2 = BrandingStore::load_or_default(dir.path());
assert!(b2.has_logo(LogoSlot::Dark));
assert!(b2.has_logo(LogoSlot::Client));
assert!(!b2.has_logo(LogoSlot::Light));
assert_eq!(b2.logo_rev(), migrated_rev, "restart must not re-migrate");
}
#[test] #[test]
fn sanitize_ext_strips_separators_and_path_chars() { fn sanitize_ext_strips_separators_and_path_chars() {
assert_eq!(sanitize_ext("svg"), "svg"); assert_eq!(sanitize_ext("svg"), "svg");
// Path separators and non-alphanumerics filter out, leaving just
// letters. The remaining "etcpasswd" exceeds the 5-char cap so
// it collapses to `bin` rather than producing `etcpa`.
assert_eq!(sanitize_ext("../etc/passwd"), "bin"); assert_eq!(sanitize_ext("../etc/passwd"), "bin");
// Short alphanumeric strip-through stays itself.
assert_eq!(sanitize_ext("../svg"), "svg"); assert_eq!(sanitize_ext("../svg"), "svg");
assert_eq!(sanitize_ext(""), "bin"); assert_eq!(sanitize_ext(""), "bin");
assert_eq!(sanitize_ext("PNG"), "png"); assert_eq!(sanitize_ext("PNG"), "png");
// Anything past five chars is suspicious — collapse to `bin`.
assert_eq!(sanitize_ext("svgvvvv"), "bin"); assert_eq!(sanitize_ext("svgvvvv"), "bin");
} }
#[test] #[test]
fn missing_file_referenced_by_json_resolves_to_empty() { fn missing_file_referenced_by_json_resolves_to_empty() {
// If the operator nukes the file out from under the JSON cache,
// we should silently fall back to no-override rather than
// hanging on to a bogus path.
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let brand_dir = dir.path().join("branding"); let brand_dir = dir.path().join("branding");
std::fs::create_dir_all(&brand_dir).unwrap(); std::fs::create_dir_all(&brand_dir).unwrap();
// branding.json claims a dark slot whose file doesn't exist. // Hand-write a branding.json claiming logo.png exists.
let inner = Inner {
logo_filename: Some("logo.png".into()),
logo_mime: Some("image/png".into()),
rev: 0,
};
std::fs::write( std::fs::write(
brand_dir.join("branding.json"), brand_dir.join("branding.json"),
br#"{"dark":{"filename":"logo-dark.png","mime":"image/png"},"rev":1}"#, serde_json::to_vec_pretty(&inner).unwrap(),
) )
.unwrap(); .unwrap();
let b = BrandingStore::load_or_default(dir.path()); let b = BrandingStore::load_or_default(dir.path());
assert!( assert!(!b.has_logo(), "should fall back when referenced file is missing");
!b.has_logo(LogoSlot::Dark),
"should fall back when referenced file is missing"
);
}
#[test]
fn slot_parse_round_trips() {
assert_eq!(LogoSlot::parse("light"), Some(LogoSlot::Light));
assert_eq!(LogoSlot::parse("DARK"), Some(LogoSlot::Dark));
assert_eq!(LogoSlot::parse(" client "), Some(LogoSlot::Client));
assert_eq!(LogoSlot::parse("nope"), None);
assert_eq!(LogoSlot::Light.as_str(), "light");
} }
#[test] #[test]
-6
View File
@@ -74,11 +74,6 @@ pub struct Paths {
/// Only used when `settings.windows_enabled = true`. Defaults to /// Only used when `settings.windows_enabled = true`. Defaults to
/// `/var/lib/openpxe/smb` in the container image. /// `/var/lib/openpxe/smb` in the container image.
pub smb_dir: PathBuf, pub smb_dir: PathBuf,
/// v0.5.2: directory holding uploaded unattended-install answer files
/// (Kickstart / Preseed / Autoinstall / Windows answer files). Kept
/// separate from `iso_dir` so answer files never appear in the ISO
/// listing or the PXE menu. Defaults to `/var/lib/openpxe/unattended`.
pub unattended_dir: PathBuf,
} }
impl Default for ServerConfig { impl Default for ServerConfig {
@@ -114,7 +109,6 @@ impl Default for Paths {
ipxe_dir: PathBuf::from("/usr/share/openpxe/ipxe"), ipxe_dir: PathBuf::from("/usr/share/openpxe/ipxe"),
wimboot_path: None, wimboot_path: None,
smb_dir: PathBuf::from("/var/lib/openpxe/smb"), smb_dir: PathBuf::from("/var/lib/openpxe/smb"),
unattended_dir: PathBuf::from("/var/lib/openpxe/unattended"),
} }
} }
} }
+7 -67
View File
@@ -21,8 +21,6 @@ use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use time::OffsetDateTime; use time::OffsetDateTime;
use crate::profile::DeployProfile;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostBinding { pub struct HostBinding {
/// Lowercase, colon-separated MAC (e.g. `aa:bb:cc:dd:ee:ff`). The /// Lowercase, colon-separated MAC (e.g. `aa:bb:cc:dd:ee:ff`). The
@@ -37,11 +35,6 @@ pub struct HostBinding {
/// `"rack-3 spine"`). Empty if unset. /// `"rack-3 spine"`). Empty if unset.
#[serde(default)] #[serde(default)]
pub label: String, pub label: String,
/// v0.5.2: optional unattended-install hints (auto hostname / IP /
/// answer-file id). Flattened into the binding JSON so pre-v0.5.2
/// `hosts.json` files (which lack these keys) still deserialize.
#[serde(default, flatten)]
pub profile: DeployProfile,
#[serde(with = "time::serde::rfc3339")] #[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime, pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")] #[serde(with = "time::serde::rfc3339")]
@@ -101,31 +94,20 @@ impl HostBindings {
} }
/// Insert or update. Returns the resulting binding (with timestamps). /// Insert or update. Returns the resulting binding (with timestamps).
/// The `profile` carries optional unattended-install hints (v0.5.2); pub fn upsert(&self, mac: &str, target: &str, label: &str) -> HostBinding {
/// pass `DeployProfile::default()` for a plain pin.
pub fn upsert(
&self,
mac: &str,
target: &str,
label: &str,
profile: DeployProfile,
) -> HostBinding {
let key = normalize_mac(mac); let key = normalize_mac(mac);
let now = OffsetDateTime::now_utc(); let now = OffsetDateTime::now_utc();
let profile = profile.normalized();
let binding = { let binding = {
let mut g = self.inner.write(); let mut g = self.inner.write();
let entry = g.by_mac.entry(key.clone()).or_insert_with(|| HostBinding { let entry = g.by_mac.entry(key.clone()).or_insert_with(|| HostBinding {
mac: key.clone(), mac: key.clone(),
target: target.to_string(), target: target.to_string(),
label: label.to_string(), label: label.to_string(),
profile: profile.clone(),
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });
entry.target = target.to_string(); entry.target = target.to_string();
entry.label = label.to_string(); entry.label = label.to_string();
entry.profile = profile.clone();
entry.updated_at = now; entry.updated_at = now;
entry.clone() entry.clone()
}; };
@@ -197,10 +179,6 @@ mod tests {
use super::*; use super::*;
use tempfile::tempdir; use tempfile::tempdir;
fn np() -> DeployProfile {
DeployProfile::default()
}
#[test] #[test]
fn normalize_handles_case_and_dashes() { fn normalize_handles_case_and_dashes() {
assert_eq!(normalize_mac("AA:BB:CC:DD:EE:FF"), "aa:bb:cc:dd:ee:ff"); assert_eq!(normalize_mac("AA:BB:CC:DD:EE:FF"), "aa:bb:cc:dd:ee:ff");
@@ -213,12 +191,7 @@ mod tests {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path()); let h = HostBindings::load_or_default(dir.path());
assert!(h.is_empty()); assert!(h.is_empty());
h.upsert( h.upsert("AA:BB:CC:00:00:01", "ubuntu-24-04-linux", "rack-3 spine");
"AA:BB:CC:00:00:01",
"ubuntu-24-04-linux",
"rack-3 spine",
np(),
);
let found = h.lookup("aa-bb-cc-00-00-01").expect("lookup"); let found = h.lookup("aa-bb-cc-00-00-01").expect("lookup");
assert_eq!(found.target, "ubuntu-24-04-linux"); assert_eq!(found.target, "ubuntu-24-04-linux");
assert_eq!(found.label, "rack-3 spine"); assert_eq!(found.label, "rack-3 spine");
@@ -229,8 +202,8 @@ mod tests {
fn upsert_replaces_existing_target() { fn upsert_replaces_existing_target() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path()); let h = HostBindings::load_or_default(dir.path());
h.upsert("aa:bb:cc:00:00:01", "old-target", "label1", np()); h.upsert("aa:bb:cc:00:00:01", "old-target", "label1");
h.upsert("aa:bb:cc:00:00:01", "new-target", "label2", np()); h.upsert("aa:bb:cc:00:00:01", "new-target", "label2");
assert_eq!(h.len(), 1); assert_eq!(h.len(), 1);
let b = h.lookup("aa:bb:cc:00:00:01").unwrap(); let b = h.lookup("aa:bb:cc:00:00:01").unwrap();
assert_eq!(b.target, "new-target"); assert_eq!(b.target, "new-target");
@@ -241,7 +214,7 @@ mod tests {
fn remove_works_and_reports_outcome() { fn remove_works_and_reports_outcome() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path()); let h = HostBindings::load_or_default(dir.path());
h.upsert("aa:bb:cc:00:00:01", "x", "", np()); h.upsert("aa:bb:cc:00:00:01", "x", "");
assert!(h.remove("AA:BB:CC:00:00:01")); assert!(h.remove("AA:BB:CC:00:00:01"));
assert!(!h.remove("aa:bb:cc:00:00:01")); // already gone assert!(!h.remove("aa:bb:cc:00:00:01")); // already gone
assert!(h.is_empty()); assert!(h.is_empty());
@@ -251,44 +224,11 @@ mod tests {
fn round_trip_persists_to_disk() { fn round_trip_persists_to_disk() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path()); let h = HostBindings::load_or_default(dir.path());
h.upsert("aa:bb:cc:00:00:01", "ubuntu-linux", "rack-3", np()); h.upsert("aa:bb:cc:00:00:01", "ubuntu-linux", "rack-3");
h.upsert("aa:bb:cc:00:00:02", "_local", "tom-laptop", np()); h.upsert("aa:bb:cc:00:00:02", "_local", "tom-laptop");
drop(h); drop(h);
let h2 = HostBindings::load_or_default(dir.path()); let h2 = HostBindings::load_or_default(dir.path());
assert_eq!(h2.len(), 2); assert_eq!(h2.len(), 2);
assert_eq!(h2.lookup("aa:bb:cc:00:00:02").unwrap().target, "_local"); assert_eq!(h2.lookup("aa:bb:cc:00:00:02").unwrap().target, "_local");
} }
#[test]
fn profile_round_trips_to_disk() {
let dir = tempdir().unwrap();
let h = HostBindings::load_or_default(dir.path());
let prof = DeployProfile {
auto_hostname: Some("node-7".into()),
auto_ip: Some("10.0.0.7".into()),
unattended_file: Some("ubuntu-ks".into()),
};
h.upsert("aa:bb:cc:00:00:09", "ubuntu-linux", "lab", prof);
drop(h);
let h2 = HostBindings::load_or_default(dir.path());
let b = h2.lookup("aa:bb:cc:00:00:09").unwrap();
assert_eq!(b.profile.auto_hostname.as_deref(), Some("node-7"));
assert_eq!(b.profile.auto_ip.as_deref(), Some("10.0.0.7"));
assert_eq!(b.profile.unattended_file.as_deref(), Some("ubuntu-ks"));
}
#[test]
fn legacy_hosts_json_without_profile_still_loads() {
// A pre-v0.5.2 hosts.json has no profile keys at all.
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("hosts.json"),
br#"[{"mac":"aa:bb:cc:00:00:01","target":"_local","label":"old","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}]"#,
)
.unwrap();
let h = HostBindings::load_or_default(dir.path());
let b = h.lookup("aa:bb:cc:00:00:01").unwrap();
assert_eq!(b.target, "_local");
assert!(b.profile.is_empty());
}
} }
+1 -3
View File
@@ -13,7 +13,6 @@ pub mod host_bindings;
pub mod log_bus; pub mod log_bus;
pub mod metrics; pub mod metrics;
pub mod notify; pub mod notify;
pub mod profile;
pub mod queue; pub mod queue;
pub mod saml; pub mod saml;
pub mod settings; pub mod settings;
@@ -23,7 +22,7 @@ pub mod wol;
pub use arch::{ClientArch, FirmwareClass}; pub use arch::{ClientArch, FirmwareClass};
pub use auth::{AdminAccount, AdminPublic, AdminStore}; pub use auth::{AdminAccount, AdminPublic, AdminStore};
pub use boot_log::{BootEvent, BootLog}; pub use boot_log::{BootEvent, BootLog};
pub use branding::{ext_for_mime, BrandingStore, LogoSlot, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES}; pub use branding::{ext_for_mime, BrandingStore, 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};
@@ -31,7 +30,6 @@ pub use host_bindings::{normalize_mac, HostBinding, HostBindings};
pub use log_bus::{LogBus, LogBusLayer, LogLine}; pub use log_bus::{LogBus, LogBusLayer, LogLine};
pub use metrics::{HttpRoute, Metrics}; pub use metrics::{HttpRoute, Metrics};
pub use notify::{NotifyConfig, NotifyKind, NotifyStore}; pub use notify::{NotifyConfig, NotifyKind, NotifyStore};
pub use profile::DeployProfile;
pub use queue::{DeploymentQueue, QueueEntry}; pub use queue::{DeploymentQueue, QueueEntry};
pub use saml::{IdpMetadata, SamlError, SpParams, VerifiedPrincipal, VerifiedResponse}; pub use saml::{IdpMetadata, SamlError, SpParams, VerifiedPrincipal, VerifiedResponse};
pub use settings::{Settings, SettingsStore, TimeoutAction}; pub use settings::{Settings, SettingsStore, TimeoutAction};
-122
View File
@@ -1,122 +0,0 @@
//! Per-host deployment profile.
//!
//! v0.5.2: a small, optional bundle of "what should this machine do when
//! it images" attached to either a pinned host binding ([`crate::HostBinding`])
//! or a queued device ([`crate::QueueEntry`]). All three fields are
//! optional and independent:
//!
//! * `auto_hostname` — substituted into the served unattended answer file
//! (`{{HOSTNAME}}`) so the installer sets the machine name.
//! * `auto_ip` — substituted as `{{IP}}`. OpenPXE is a DHCP **proxy** and
//! does not hand out leases, so this is applied by the installer as a
//! static-network directive inside the answer file, not by DHCP.
//! * `unattended_file` — the id of an uploaded file in the unattended
//! store (Kickstart / Preseed / Autoinstall / Windows answer file). When
//! set, the boot chain injects the appropriate kernel argument so the
//! install runs unattended.
use serde::{Deserialize, Serialize};
/// Optional deployment hints carried on a host pin or a queue entry.
///
/// The fields are flattened into `HostBinding` / `QueueEntry` on the wire
/// (so existing JSON stays compatible via `#[serde(default)]`); this type
/// is the in-code bundle the boot chain consumes.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeployProfile {
/// Hostname to set on the imaged machine (`{{HOSTNAME}}`). Empty/None
/// leaves the installer default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_hostname: Option<String>,
/// Static IPv4/IPv6 the installer should configure (`{{IP}}`). Stored
/// as a free-form string — validated lightly at the HTTP layer.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_ip: Option<String>,
/// Id of an uploaded file in the unattended store. Empty/None means
/// "no unattended install — boot interactively".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unattended_file: Option<String>,
}
/// Cap on the stored hostname / IP strings — generous for any real value
/// but bounds what an operator can stuff into the JSON.
pub const MAX_PROFILE_FIELD_LEN: usize = 255;
impl DeployProfile {
/// True when nothing is set — lets call sites skip work entirely.
#[must_use]
pub fn is_empty(&self) -> bool {
self.auto_hostname.is_none() && self.auto_ip.is_none() && self.unattended_file.is_none()
}
/// True when an unattended file is selected (drives boot-chain injection).
#[must_use]
pub fn has_unattended(&self) -> bool {
self.unattended_file
.as_deref()
.is_some_and(|s| !s.trim().is_empty())
}
/// Normalise: trim every field and collapse empty strings to `None`
/// so persisted JSON never carries `""` for an unset value.
#[must_use]
pub fn normalized(mut self) -> Self {
fn clean(v: Option<String>) -> Option<String> {
v.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.map(|s| s.chars().take(MAX_PROFILE_FIELD_LEN).collect())
}
self.auto_hostname = clean(self.auto_hostname);
self.auto_ip = clean(self.auto_ip);
self.unattended_file = clean(self.unattended_file);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_profile_is_empty() {
assert!(DeployProfile::default().is_empty());
assert!(!DeployProfile::default().has_unattended());
}
#[test]
fn normalize_trims_and_nulls_empty() {
let p = DeployProfile {
auto_hostname: Some(" node-7 ".into()),
auto_ip: Some(" ".into()),
unattended_file: Some(String::new()),
}
.normalized();
assert_eq!(p.auto_hostname.as_deref(), Some("node-7"));
assert_eq!(p.auto_ip, None);
assert_eq!(p.unattended_file, None);
assert!(!p.is_empty());
}
#[test]
fn has_unattended_detects_real_id() {
let p = DeployProfile {
unattended_file: Some("ubuntu-ks".into()),
..Default::default()
};
assert!(p.has_unattended());
}
#[test]
fn long_field_is_capped() {
let long = "a".repeat(1000);
let p = DeployProfile {
auto_hostname: Some(long),
..Default::default()
}
.normalized();
assert_eq!(
p.auto_hostname.as_deref().map(str::len),
Some(MAX_PROFILE_FIELD_LEN)
);
}
}
-32
View File
@@ -21,7 +21,6 @@ use time::OffsetDateTime;
use tokio::sync::Notify; use tokio::sync::Notify;
use uuid::Uuid; use uuid::Uuid;
use crate::profile::DeployProfile;
use crate::ClientArch; use crate::ClientArch;
/// Per-client queue state visible to the WebUI. /// Per-client queue state visible to the WebUI.
@@ -38,11 +37,6 @@ pub struct QueueEntry {
#[serde(with = "time::serde::rfc3339")] #[serde(with = "time::serde::rfc3339")]
pub last_poll_at: OffsetDateTime, pub last_poll_at: OffsetDateTime,
pub assigned_target: Option<String>, pub assigned_target: Option<String>,
/// v0.5.2: optional per-device deployment profile set via the queue
/// "Profile" button (auto hostname / IP / unattended file). Flattened
/// so the JSON stays flat alongside the other queue fields.
#[serde(default, flatten)]
pub profile: DeployProfile,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -55,7 +49,6 @@ struct QueueEntryInner {
joined_at: OffsetDateTime, joined_at: OffsetDateTime,
last_poll_at: OffsetDateTime, last_poll_at: OffsetDateTime,
assigned_target: Option<String>, assigned_target: Option<String>,
profile: DeployProfile,
/// Broadcast primitive that wakes the long-poll as soon as an /// Broadcast primitive that wakes the long-poll as soon as an
/// assignment lands — no polling on our side, no sleep-loops. /// assignment lands — no polling on our side, no sleep-loops.
notify: Arc<Notify>, notify: Arc<Notify>,
@@ -72,7 +65,6 @@ impl QueueEntryInner {
joined_at: self.joined_at, joined_at: self.joined_at,
last_poll_at: self.last_poll_at, last_poll_at: self.last_poll_at,
assigned_target: self.assigned_target.clone(), assigned_target: self.assigned_target.clone(),
profile: self.profile.clone(),
} }
} }
} }
@@ -118,7 +110,6 @@ impl DeploymentQueue {
joined_at: now, joined_at: now,
last_poll_at: now, last_poll_at: now,
assigned_target: None, assigned_target: None,
profile: DeployProfile::default(),
notify: Arc::new(Notify::new()), notify: Arc::new(Notify::new()),
}; };
let snap = inner.snapshot(); let snap = inner.snapshot();
@@ -142,29 +133,6 @@ impl DeploymentQueue {
Some(g.snapshot()) Some(g.snapshot())
} }
/// Operator sets (or clears) the deployment profile for a queued
/// device via the WebUI "Profile" button. Returns the updated
/// snapshot, or `None` if the entry has since been released.
pub fn set_profile(&self, entry_id: &str, profile: DeployProfile) -> Option<QueueEntry> {
let mut guard = self.inner.write();
let g = guard.get_mut(entry_id)?;
g.profile = profile.normalized();
Some(g.snapshot())
}
/// Look up the deployment profile for a queued MAC, if any. Used by
/// the boot chain to inject an unattended file / template the
/// hostname + IP when an assigned device chains to its target.
#[must_use]
pub fn profile_for_mac(&self, mac: &str) -> Option<DeployProfile> {
let guard = self.inner.read();
guard
.values()
.find(|g| g.mac == mac)
.map(|g| g.profile.clone())
.filter(|p| !p.is_empty())
}
/// Operator assigns an ISO entry (boot_entry id) to one or more clients. /// Operator assigns an ISO entry (boot_entry id) to one or more clients.
/// Returns the number of queue entries that were updated. Entries not in the /// Returns the number of queue entries that were updated. Entries not in the
/// queue are silently skipped. /// queue are silently skipped.
+35 -518
View File
@@ -31,14 +31,11 @@ use axum::{
Json, Router, Json, Router,
}; };
use openpxe_core::{ use openpxe_core::{
ext_for_mime, wol, BootEvent, ClientEvent, DeployProfile, Error, LogoSlot, NotifyConfig, ext_for_mime, wol, BootEvent, ClientEvent, Error, NotifyConfig, Settings, SsoConfig,
Settings, SsoConfig, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
}; };
use openpxe_ipxe_assets::asset_bytes; use openpxe_ipxe_assets::asset_bytes;
use openpxe_iso_store::{ use openpxe_iso_store::{IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest};
render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest, UnattendedKind,
UnattendedMeta,
};
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use std::net::SocketAddr; use std::net::SocketAddr;
@@ -52,14 +49,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/", get(index)) .route("/", get(index))
.route("/assets/app.js", get(ui_js)) .route("/assets/app.js", get(ui_js))
.route("/assets/app.css", get(ui_css)) .route("/assets/app.css", get(ui_css))
// v0.5.2: theme-aware brand mark. `?theme=light|dark` selects the
// operator's per-theme logo slot (falling back across themes, then
// to the bundled mark). The WebUI swaps `?theme=` on theme toggle.
.route("/assets/logo.svg", get(ui_logo)) .route("/assets/logo.svg", get(ui_logo))
// v0.5.2: favicon is pinned to the *bundled* OpenPXE mark for
// continuity — it never follows the operator's custom branding, so
// the browser-tab icon stays recognisably "OpenPXE".
.route("/assets/favicon.svg", get(ui_favicon))
.route("/assets/loader.svg", get(ui_loader)) .route("/assets/loader.svg", get(ui_loader))
// v0.4.6: PXE menu logo — the raster form of the operator's // v0.4.6: PXE menu logo — the raster form of the operator's
// uploaded mark, served so iPXE's `console --picture` can // uploaded mark, served so iPXE's `console --picture` can
@@ -71,16 +61,6 @@ pub fn build_router(state: AppState) -> Router {
// iPXE script endpoints. // iPXE script endpoints.
.route("/boot.ipxe", get(boot_top_menu)) .route("/boot.ipxe", get(boot_top_menu))
.route("/boot/:filename", get(boot_sub)) .route("/boot/:filename", get(boot_sub))
// v0.5.2: unattended answer-file *serving* — public (like /iso),
// because the booting installer fetches these with no session.
// `/unattended/:id` serves a Kickstart/Preseed with `{{HOSTNAME}}`
// / `{{IP}}` / `{{MAC}}` substituted from the query string. The
// 3-segment form is the cloud-init NoCloud seed dir for Ubuntu
// autoinstall (`…/<ctx>/user-data` + `/meta-data`), where `<ctx>`
// base64url-encodes the per-host hostname/ip/mac. Management
// (upload/list/delete) lives under the gated `/api/unattended`.
.route("/unattended/:id", get(serve_unattended))
.route("/unattended/:id/:ctx/:sub", get(serve_unattended_seed))
// Bundled binaries and raw ISO access. // Bundled binaries and raw ISO access.
.route("/ipxe/:name", get(ipxe_binary)) .route("/ipxe/:name", get(ipxe_binary))
.route("/iso/:filename", get(iso_raw)) .route("/iso/:filename", get(iso_raw))
@@ -115,21 +95,12 @@ pub fn build_router(state: AppState) -> Router {
// volume — surfaced as a small card on the Storage tab so the // volume — surfaced as a small card on the Storage tab so the
// operator knows when they're about to run out of room. // operator knows when they're about to run out of room.
.route("/api/storage/disk", get(api_storage_disk)) .route("/api/storage/disk", get(api_storage_disk))
// v0.4.4: operator-controlled WebUI branding overrides (custom // v0.4.4: operator-controlled WebUI branding overrides
// logo). v0.5.2: split into three slots — `light` / `dark` / // (custom logo). Multipart upload to POST; DELETE clears.
// `client`. Multipart upload to POST; DELETE clears one slot.
.route( .route(
"/api/branding/logo/:slot", "/api/branding/logo",
post(api_branding_upload).delete(api_branding_clear), post(api_branding_upload).delete(api_branding_clear),
) )
// v0.5.2: unattended-install answer-file management (gated).
// Multipart upload, list, delete. Serving is the public
// `/unattended/*` routes above.
.route(
"/api/unattended",
get(api_unattended_list).post(api_unattended_upload),
)
.route("/api/unattended/:id", delete(api_unattended_delete))
// v0.4.4: self-rendered API reference, served as JSON so the UI // v0.4.4: self-rendered API reference, served as JSON so the UI
// can format it consistently with the rest of the chrome. Lives // can format it consistently with the rest of the chrome. Lives
// under the Settings tab — operators chasing an integration get // under the Settings tab — operators chasing an integration get
@@ -162,9 +133,6 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/queue/join", get(api_queue_join)) .route("/api/queue/join", get(api_queue_join))
.route("/api/queue/poll/:entry_id", get(api_queue_poll)) .route("/api/queue/poll/:entry_id", get(api_queue_poll))
.route("/api/queue/assign", post(api_queue_assign)) .route("/api/queue/assign", post(api_queue_assign))
// v0.5.2: per-device deployment profile (auto hostname / IP /
// unattended file) set from the queue "Profile" button.
.route("/api/queue/:entry_id/profile", put(api_queue_set_profile))
.route("/api/queue/:entry_id", delete(api_queue_release)) .route("/api/queue/:entry_id", delete(api_queue_release))
// v0.4.65: SMB share manager (userspace via smbclient). The // v0.4.65: SMB share manager (userspace via smbclient). The
// kernel-mount NFS routes that v0.4.64 shipped are gone — they // kernel-mount NFS routes that v0.4.64 shipped are gone — they
@@ -263,7 +231,7 @@ async fn index(State(state): State<AppState>) -> Response {
&state.public_base_url, &state.public_base_url,
env!("CARGO_PKG_VERSION"), env!("CARGO_PKG_VERSION"),
state.branding.logo_rev(), state.branding.logo_rev(),
state.branding.has_any_web_logo(), state.branding.has_logo(),
); );
( (
[ [
@@ -316,28 +284,23 @@ async fn ui_css() -> Response {
.into_response() .into_response()
} }
#[derive(Debug, Deserialize)] async fn ui_logo(State(state): State<AppState>) -> Response {
struct LogoQuery {
/// `light` or `dark` — which theme variant the page is currently
/// showing. Anything else (or absent) resolves to the dark slot,
/// which matches the default theme.
#[serde(default)]
theme: Option<String>,
}
async fn ui_logo(State(state): State<AppState>, Query(q): Query<LogoQuery>) -> Response {
// Custom override first; fall back to the bundled rainbow-horizon // Custom override first; fall back to the bundled rainbow-horizon
// SVG. We resolve the override on each request rather than caching // SVG. We resolve the override on each request rather than caching
// because operators may upload/clear from the Settings tab while the // because operators may upload/clear from the Settings tab while the
// server is live, and we want them to see their change immediately // server is live, and we want them to see their change immediately
// without bouncing the binary. The theme query selects the per-theme // without bouncing the binary.
// slot, with cross-theme + bundled fallback handled in BrandingStore. if let Some(path) = state.branding.logo_path() {
let theme_is_light = q.theme.as_deref() == Some("light"); let mime = state
if let Some((path, mime)) = state.branding.web_logo(theme_is_light) { .branding
.logo_mime()
.unwrap_or_else(|| "image/svg+xml".to_string());
match tokio::fs::read(&path).await { match tokio::fs::read(&path).await {
Ok(bytes) => { Ok(bytes) => {
let ct = HeaderValue::from_str(&mime) let ct = match HeaderValue::from_str(&mime) {
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")); Ok(v) => v,
Err(_) => HeaderValue::from_static("application/octet-stream"),
};
return ( return (
[ [
(header::CONTENT_TYPE, ct), (header::CONTENT_TYPE, ct),
@@ -358,17 +321,6 @@ async fn ui_logo(State(state): State<AppState>, Query(q): Query<LogoQuery>) -> R
} }
} }
} }
bundled_logo_response()
}
/// Favicon — always the bundled OpenPXE mark, decoupled from operator
/// branding (v0.5.2) so the browser-tab icon stays "OpenPXE" for
/// continuity regardless of any uploaded light/dark logo.
async fn ui_favicon() -> Response {
bundled_logo_response()
}
fn bundled_logo_response() -> Response {
( (
[ [
( (
@@ -399,8 +351,8 @@ async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
// Resolve the operator's raster upload, if any and if it's a format // Resolve the operator's raster upload, if any and if it's a format
// iPXE/our compositor can consume. SVG (or a missing/unreadable // iPXE/our compositor can consume. SVG (or a missing/unreadable
// file) yields `None`, which composes the default background. // file) yields `None`, which composes the default background.
let raster: Option<Vec<u8>> = match state.branding.client_logo() { let raster: Option<Vec<u8>> = match (state.branding.logo_path(), state.branding.logo_mime()) {
Some((path, mime)) if mime != "image/svg+xml" => tokio::fs::read(&path).await.ok(), (Some(path), Some(mime)) if mime != "image/svg+xml" => tokio::fs::read(&path).await.ok(),
_ => None, _ => None,
}; };
@@ -690,26 +642,7 @@ async fn boot_sub(
"PXE boot started", "PXE boot started",
&format!("{who} started booting {} ({}).", iso.filename, entry.title), &format!("{who} started booting {} ({}).", iso.filename, entry.title),
); );
// v0.5.2: if this MAC has a deployment profile with return text_plain(render_entry(entry, &settings, base));
// an unattended answer file selected (via a host
// pin or queue Profile), inject the right kernel
// arg so the install runs unattended.
let unattended_args = mac_normalized.as_deref().and_then(|m| {
resolve_profile(&state, m).and_then(|p| {
p.unattended_file
.as_deref()
.and_then(|fid| state.unattended.get(fid))
.and_then(|meta| {
build_unattended_args(base, &meta, Some(m), &p)
})
})
});
return text_plain(render_entry(
entry,
&settings,
base,
unattended_args.as_deref(),
));
} }
} }
} }
@@ -1119,18 +1052,7 @@ async fn api_storage_disk(State(state): State<AppState>) -> Json<serde_json::Val
// ─── Branding (custom logo) ─────────────────────────────────────────────── // ─── Branding (custom logo) ───────────────────────────────────────────────
async fn api_branding_upload( async fn api_branding_upload(State(state): State<AppState>, mut multipart: Multipart) -> Response {
State(state): State<AppState>,
AxumPath(slot): AxumPath<String>,
mut multipart: Multipart,
) -> Response {
let Some(slot) = LogoSlot::parse(&slot) else {
return (
StatusCode::BAD_REQUEST,
"unknown logo slot; expected light, dark, or client",
)
.into_response();
};
while let Ok(Some(field)) = multipart.next_field().await { while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string(); let name = field.name().unwrap_or("").to_string();
if name != "file" && name != "logo" { if name != "file" && name != "logo" {
@@ -1167,21 +1089,11 @@ async fn api_branding_upload(
let Some(ext) = ext_for_mime(&mime) else { let Some(ext) = ext_for_mime(&mime) else {
return (StatusCode::BAD_REQUEST, "unsupported MIME").into_response(); return (StatusCode::BAD_REQUEST, "unsupported MIME").into_response();
}; };
// The PXE "client" logo is rasterized for the boot screen; an SVG match state.branding.set_logo(&mime, ext, &bytes) {
// there can't be composited, so steer operators to a raster.
if slot == LogoSlot::Client && mime == "image/svg+xml" {
return (
StatusCode::BAD_REQUEST,
"the client (PXE) logo must be a raster image (PNG/JPEG/WebP/GIF); SVG can't be painted on the boot screen",
)
.into_response();
}
match state.branding.set_logo(slot, &mime, ext, &bytes) {
Ok(filename) => { Ok(filename) => {
return ( return (
StatusCode::OK, StatusCode::OK,
Json(json!({ Json(json!({
"slot": slot.as_str(),
"filename": filename, "filename": filename,
"mime": mime, "mime": mime,
"size_bytes": bytes.len(), "size_bytes": bytes.len(),
@@ -1195,238 +1107,13 @@ async fn api_branding_upload(
(StatusCode::BAD_REQUEST, "no 'file' part").into_response() (StatusCode::BAD_REQUEST, "no 'file' part").into_response()
} }
async fn api_branding_clear( async fn api_branding_clear(State(state): State<AppState>) -> Response {
State(state): State<AppState>, match state.branding.clear_logo() {
AxumPath(slot): AxumPath<String>,
) -> Response {
let Some(slot) = LogoSlot::parse(&slot) else {
return (StatusCode::BAD_REQUEST, "unknown logo slot").into_response();
};
match state.branding.clear_logo(slot) {
Ok(()) => StatusCode::NO_CONTENT.into_response(), Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
} }
} }
// ─── Unattended answer files (v0.5.2) ──────────────────────────────────────
//
// Management (list/upload/delete) is gated behind the auth middleware.
// *Serving* the files to the booting installer is the public
// `/unattended/*` route pair below — the installer has no session.
async fn api_unattended_list(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!({ "files": state.unattended.list() }))
}
async fn api_unattended_upload(
State(state): State<AppState>,
mut multipart: Multipart,
) -> Response {
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
if name != "file" && name != "unattended" {
continue;
}
let filename = field.file_name().map(str::to_string).unwrap_or_default();
if filename.trim().is_empty() {
return (StatusCode::BAD_REQUEST, "missing filename on upload").into_response();
}
let bytes = match field.bytes().await {
Ok(b) => b,
Err(e) => return (StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response(),
};
return match state.unattended.add(&filename, &bytes).await {
Ok(meta) => (StatusCode::CREATED, Json(meta)).into_response(),
Err(Error::Invalid(msg)) => (StatusCode::BAD_REQUEST, msg).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
};
}
(StatusCode::BAD_REQUEST, "no 'file' part").into_response()
}
async fn api_unattended_delete(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> StatusCode {
if state.unattended.remove(&id).await {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
#[derive(Debug, Deserialize)]
struct UnattendedServeQuery {
#[serde(default)]
mac: Option<String>,
#[serde(default)]
hostname: Option<String>,
#[serde(default)]
ip: Option<String>,
}
/// Public: serve a Kickstart/Preseed/answer file with `{{HOSTNAME}}` /
/// `{{IP}}` / `{{MAC}}` substituted from the query string. Returns
/// `text/plain` so installers (anaconda, debian-installer, Windows setup
/// fetching over HTTP) read it verbatim.
async fn serve_unattended(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
Query(q): Query<UnattendedServeQuery>,
) -> Response {
let Ok(bytes) = state.unattended.read(&id).await else {
return (StatusCode::NOT_FOUND, "no such unattended file").into_response();
};
let content = String::from_utf8_lossy(&bytes);
let rendered = render_template(
&content,
q.mac.as_deref(),
q.hostname.as_deref(),
q.ip.as_deref(),
);
text_plain(rendered)
}
/// Public: cloud-init NoCloud seed directory for Ubuntu autoinstall. The
/// kernel arg points iPXE/cloud-init at `…/<id>/<ctx>/`; cloud-init then
/// fetches `user-data`, `meta-data`, (and `vendor-data`). `<ctx>`
/// base64url-encodes the per-host hostname/ip/mac so they survive the
/// seedfrom URL (which can't carry a query string).
async fn serve_unattended_seed(
State(state): State<AppState>,
AxumPath((id, ctx, sub)): AxumPath<(String, String, String)>,
) -> Response {
let (mac, hostname, ip) = decode_seed_ctx(&ctx);
match sub.as_str() {
"user-data" => {
let Ok(bytes) = state.unattended.read(&id).await else {
return (StatusCode::NOT_FOUND, "no such unattended file").into_response();
};
let content = String::from_utf8_lossy(&bytes);
let rendered =
render_template(&content, mac.as_deref(), hostname.as_deref(), ip.as_deref());
text_plain(rendered)
}
"meta-data" => {
let host_line = hostname
.as_deref()
.map(|h| format!("local-hostname: {h}\n"))
.unwrap_or_default();
text_plain(format!("instance-id: openpxe-{id}\n{host_line}"))
}
// cloud-init probes vendor-data too; an empty 200 keeps it quiet.
"vendor-data" => text_plain(String::new()),
_ => (StatusCode::NOT_FOUND, "unknown seed resource").into_response(),
}
}
/// Resolve the deployment profile for a booting MAC: a host pin wins, else
/// a queued device's Profile. `None` when neither carries one.
fn resolve_profile(state: &AppState, mac: &str) -> Option<DeployProfile> {
if let Some(b) = state.hosts.lookup(mac) {
if !b.profile.is_empty() {
return Some(b.profile);
}
}
state.queue.profile_for_mac(mac)
}
/// Build the per-host unattended kernel arguments for a Linux entry.
/// Returns `None` for Windows answer files / unclassified uploads (no
/// kernel cmdline injection applies).
fn build_unattended_args(
base: &str,
meta: &UnattendedMeta,
mac: Option<&str>,
profile: &DeployProfile,
) -> Option<String> {
let base = base.trim_end_matches('/');
let id = &meta.id;
let host = profile.auto_hostname.as_deref();
let ip = profile.auto_ip.as_deref();
let query = build_query(&[("mac", mac), ("hostname", host), ("ip", ip)]);
match meta.kind {
UnattendedKind::Kickstart => Some(format!("inst.ks={base}/unattended/{id}{query}")),
UnattendedKind::Preseed => {
let mut s = format!("auto=true priority=critical url={base}/unattended/{id}{query}");
if let Some(h) = host {
s.push_str(" hostname=");
s.push_str(h);
}
Some(s)
}
UnattendedKind::Autoinstall => {
let ctx = encode_seed_ctx(mac, host, ip);
Some(format!(
"autoinstall ds=nocloud-net;s={base}/unattended/{id}/{ctx}/"
))
}
UnattendedKind::AnswerFile | UnattendedKind::Unknown => None,
}
}
/// Build a `?k=v&…` query string from present key/value pairs, percent-
/// encoding the values. Empty when nothing is present.
fn build_query(pairs: &[(&str, Option<&str>)]) -> String {
use std::fmt::Write as _;
let mut out = String::new();
for (k, v) in pairs {
if let Some(val) = v {
out.push(if out.is_empty() { '?' } else { '&' });
let _ = write!(out, "{k}={}", pct_encode(val));
}
}
out
}
/// Minimal RFC 3986 percent-encoding for query values (unreserved set
/// passes through; everything else becomes `%XX`).
fn pct_encode(s: &str) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
let _ = write!(out, "%{b:02X}");
}
}
}
out
}
/// Encode `(hostname, ip, mac)` into a single base64url path segment for
/// the cloud-init seed directory. Empty values become empty fields.
fn encode_seed_ctx(mac: Option<&str>, hostname: Option<&str>, ip: Option<&str>) -> String {
use base64::Engine as _;
let raw = format!(
"{}\n{}\n{}",
hostname.unwrap_or(""),
ip.unwrap_or(""),
mac.unwrap_or("")
);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes())
}
/// Inverse of [`encode_seed_ctx`]; returns `(mac, hostname, ip)`. A bad
/// or empty segment yields all-`None` so the seed still serves (just
/// without per-host substitution).
fn decode_seed_ctx(ctx: &str) -> (Option<String>, Option<String>, Option<String>) {
use base64::Engine as _;
let Ok(bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(ctx.as_bytes()) else {
return (None, None, None);
};
let s = String::from_utf8_lossy(&bytes).into_owned();
let mut it = s.splitn(3, '\n');
let clean = |v: Option<&str>| v.map(str::to_string).filter(|x| !x.is_empty());
let hostname = clean(it.next());
let ip = clean(it.next());
let mac = clean(it.next());
(mac, hostname, ip)
}
// ─── API reference (Settings → bottom) ──────────────────────────────────── // ─── API reference (Settings → bottom) ────────────────────────────────────
async fn api_docs() -> Json<serde_json::Value> { async fn api_docs() -> Json<serde_json::Value> {
@@ -1522,12 +1209,12 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "Current runtime settings (Windows toggle, timeout, dns hint, …)."}, "summary": "Current runtime settings (Windows toggle, timeout, dns hint, …)."},
{"method": "PUT", "path": "/api/settings", {"method": "PUT", "path": "/api/settings",
"summary": "Replace runtime settings. Guards against enabling Windows when wimboot isn't bundled."}, "summary": "Replace runtime settings. Guards against enabling Windows when wimboot isn't bundled."},
{"method": "POST", "path": "/api/branding/logo/:slot", {"method": "POST", "path": "/api/branding/logo",
"summary": "Upload a custom logo for a slot (light | dark | client). Multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB. The client slot is raster-only."}, "summary": "Upload a custom WebUI logo (multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB)."},
{"method": "DELETE", "path": "/api/branding/logo/:slot", {"method": "DELETE", "path": "/api/branding/logo",
"summary": "Remove the custom logo for a slot and revert to the bundled mark."}, "summary": "Remove the custom logo and revert to the bundled mark."},
{"method": "GET", "path": "/branding/pxe-logo", {"method": "GET", "path": "/branding/pxe-logo",
"summary": "Raster form of the operator's 'client' logo for the iPXE menu's `console --picture`. Default background when unset/SVG."}, "summary": "Raster form of the operator's logo for the iPXE menu's `console --picture`. SVG uploads 404 here."},
{"method": "GET", "path": "/api/sso", {"method": "GET", "path": "/api/sso",
"summary": "Current SAML SSO configuration."}, "summary": "Current SAML SSO configuration."},
{"method": "PUT", "path": "/api/sso", {"method": "PUT", "path": "/api/sso",
@@ -1558,28 +1245,13 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "Free / used / total bytes for the volume hosting the ISO directory."}, "summary": "Free / used / total bytes for the volume hosting the ISO directory."},
], ],
}, },
{
"name": "Unattended answer files",
"endpoints": [
{"method": "GET", "path": "/api/unattended",
"summary": "List uploaded answer files (Kickstart / Preseed / Autoinstall / Windows answer file)."},
{"method": "POST", "path": "/api/unattended",
"summary": "Upload an answer file (multipart 'file', .ks/.cfg/.seed/.yaml/.yml/.xml/user-data, up to 1 MB)."},
{"method": "DELETE", "path": "/api/unattended/:id",
"summary": "Delete an uploaded answer file."},
{"method": "GET", "path": "/unattended/:id",
"summary": "Public: serve an answer file with {{HOSTNAME}}/{{IP}}/{{MAC}} substituted from the query string."},
],
},
{ {
"name": "Queued Deployment", "name": "Queued Deployment",
"endpoints": [ "endpoints": [
{"method": "GET", "path": "/api/queue", {"method": "GET", "path": "/api/queue",
"summary": "List queue entries (waiting + assigned, with any deployment profile)."}, "summary": "List queue entries (waiting + assigned)."},
{"method": "POST", "path": "/api/queue/assign", {"method": "POST", "path": "/api/queue/assign",
"summary": "Assign a target image to queued clients. Body: { target, entry_ids }."}, "summary": "Assign a target image to queued clients. Body: { target, entry_ids }."},
{"method": "PUT", "path": "/api/queue/:entry_id/profile",
"summary": "Set a queued device's deployment profile. Body: { auto_hostname?, auto_ip?, unattended_file? }."},
{"method": "DELETE", "path": "/api/queue/:entry_id", {"method": "DELETE", "path": "/api/queue/:entry_id",
"summary": "Release a queue entry without assigning."}, "summary": "Release a queue entry without assigning."},
], ],
@@ -1590,7 +1262,7 @@ async fn api_docs() -> Json<serde_json::Value> {
{"method": "GET", "path": "/api/hosts", {"method": "GET", "path": "/api/hosts",
"summary": "List per-MAC boot bindings."}, "summary": "List per-MAC boot bindings."},
{"method": "POST", "path": "/api/hosts", {"method": "POST", "path": "/api/hosts",
"summary": "Pin a MAC to a boot target. Body: { mac, target, label, auto_hostname?, auto_ip?, unattended_file? }."}, "summary": "Pin a MAC to a boot target. Body: { mac, target, label }."},
{"method": "DELETE", "path": "/api/hosts/:mac", {"method": "DELETE", "path": "/api/hosts/:mac",
"summary": "Remove a binding."}, "summary": "Remove a binding."},
{"method": "POST", "path": "/api/hosts/:mac/wol", {"method": "POST", "path": "/api/hosts/:mac/wol",
@@ -2000,14 +1672,7 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
"nfs_share_count": nfs_shares.len(), "nfs_share_count": nfs_shares.len(),
"nfs_share_reachable": nfs_reachable, "nfs_share_reachable": nfs_reachable,
"host_bindings": state.hosts.len(), "host_bindings": state.hosts.len(),
"custom_logo": state.branding.has_any_web_logo(), "custom_logo": state.branding.has_logo(),
"branding": {
"light": state.branding.has_logo(LogoSlot::Light),
"dark": state.branding.has_logo(LogoSlot::Dark),
"client": state.branding.has_logo(LogoSlot::Client),
"rev": state.branding.logo_rev(),
},
"unattended_count": state.unattended.len(),
"uptime_secs": uptime_secs, "uptime_secs": uptime_secs,
"started_at": state.started_at, "started_at": state.started_at,
"nic_name": state.nic_name, "nic_name": state.nic_name,
@@ -2154,15 +1819,10 @@ async fn api_queue_poll(
entry_id=%entry_id, mac=%g.mac, target=%target, entry_id=%entry_id, mac=%g.mac, target=%target,
"queue assignment delivered" "queue assignment delivered"
); );
// Carry `?mac=` so the per-entry handler can resolve this
// device's deployment profile (auto hostname/IP + unattended
// file) and inject the unattended kernel args, mirroring the
// pinned-host path.
let qmac = g.mac.clone();
text_plain(format!( text_plain(format!(
"#!ipxe\n\ "#!ipxe\n\
echo Queue assignment received: {target}\n\ echo Queue assignment received: {target}\n\
chain {base}/boot/{target}.ipxe?mac={qmac} || chain {base}/api/queue/poll/{entry_id}\n" chain {base}/boot/{target}.ipxe || chain {base}/api/queue/poll/{entry_id}\n"
)) ))
} }
Some(g) => { Some(g) => {
@@ -2215,21 +1875,6 @@ async fn api_queue_assign(
Json(json!({ "ok": true, "assigned": n, "target": body.target })) Json(json!({ "ok": true, "assigned": n, "target": body.target }))
} }
async fn api_queue_set_profile(
State(state): State<AppState>,
AxumPath(entry_id): AxumPath<String>,
Json(body): Json<DeployProfile>,
) -> Response {
let profile = body.normalized();
if let Err(msg) = validate_profile(&state, &profile) {
return (StatusCode::BAD_REQUEST, msg).into_response();
}
match state.queue.set_profile(&entry_id, profile) {
Some(entry) => (StatusCode::OK, Json(entry)).into_response(),
None => (StatusCode::NOT_FOUND, "no such queue entry").into_response(),
}
}
async fn api_queue_release( async fn api_queue_release(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(entry_id): AxumPath<String>, AxumPath(entry_id): AxumPath<String>,
@@ -2368,11 +2013,6 @@ struct HostsUpsertBody {
target: String, target: String,
#[serde(default)] #[serde(default)]
label: String, label: String,
/// v0.5.2: optional unattended-install profile. Flattened so the
/// front-end posts `auto_hostname` / `auto_ip` / `unattended_file`
/// at the top level alongside mac/target/label.
#[serde(default, flatten)]
profile: DeployProfile,
} }
async fn api_hosts_upsert( async fn api_hosts_upsert(
@@ -2400,32 +2040,10 @@ async fn api_hosts_upsert(
) )
.into_response(); .into_response();
} }
let profile = body.profile.normalized(); let binding = state.hosts.upsert(mac, target, body.label.trim());
if let Err(msg) = validate_profile(&state, &profile) {
return (StatusCode::BAD_REQUEST, msg).into_response();
}
let binding = state.hosts.upsert(mac, target, body.label.trim(), profile);
(StatusCode::CREATED, Json(binding)).into_response() (StatusCode::CREATED, Json(binding)).into_response()
} }
/// Shared validation for a deployment profile (host pin + queue profile):
/// the referenced unattended file must exist, and a supplied IP must
/// parse. Hostname is free-form (installers vary), so we only length-cap
/// it (done in `DeployProfile::normalized`).
fn validate_profile(state: &AppState, profile: &DeployProfile) -> Result<(), String> {
if let Some(id) = profile.unattended_file.as_deref() {
if state.unattended.get(id).is_none() {
return Err(format!("unknown unattended file: {id}"));
}
}
if let Some(ip) = profile.auto_ip.as_deref() {
if ip.parse::<std::net::IpAddr>().is_err() {
return Err(format!("auto_ip is not a valid IP address: {ip}"));
}
}
Ok(())
}
async fn api_hosts_remove( async fn api_hosts_remove(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(mac): AxumPath<String>, AxumPath(mac): AxumPath<String>,
@@ -2708,107 +2326,6 @@ async fn api_metrics(State(state): State<AppState>) -> Response {
mod tests { mod tests {
use super::*; use super::*;
fn meta(kind: UnattendedKind) -> UnattendedMeta {
UnattendedMeta {
id: "ks1".into(),
filename: "f".into(),
kind,
size_bytes: 0,
uploaded_at: time::OffsetDateTime::UNIX_EPOCH,
}
}
#[test]
fn unattended_kickstart_arg_carries_query() {
let p = DeployProfile {
auto_hostname: Some("node7".into()),
auto_ip: Some("10.0.0.7".into()),
unattended_file: Some("ks1".into()),
};
let a = build_unattended_args(
"http://h",
&meta(UnattendedKind::Kickstart),
Some("aa:bb:cc:dd:ee:ff"),
&p,
)
.unwrap();
assert!(a.starts_with("inst.ks=http://h/unattended/ks1?"), "{a}");
assert!(a.contains("hostname=node7"), "{a}");
assert!(a.contains("ip=10.0.0.7"), "{a}");
// MAC colons are percent-encoded.
assert!(a.contains("mac=aa%3Abb%3Acc%3Add%3Aee%3Aff"), "{a}");
}
#[test]
fn unattended_preseed_appends_hostname_kernel_arg() {
let p = DeployProfile {
auto_hostname: Some("deb1".into()),
..Default::default()
};
let a =
build_unattended_args("http://h/", &meta(UnattendedKind::Preseed), None, &p).unwrap();
assert!(
a.starts_with("auto=true priority=critical url=http://h/unattended/ks1"),
"{a}"
);
assert!(a.ends_with(" hostname=deb1"), "{a}");
}
#[test]
fn unattended_autoinstall_uses_nocloud_seed_dir() {
let p = DeployProfile {
auto_hostname: Some("u1".into()),
auto_ip: Some("10.1.1.5".into()),
unattended_file: Some("ks1".into()),
};
let a = build_unattended_args(
"http://h",
&meta(UnattendedKind::Autoinstall),
Some("aa:bb"),
&p,
)
.unwrap();
assert!(
a.starts_with("autoinstall ds=nocloud-net;s=http://h/unattended/ks1/"),
"{a}"
);
assert!(a.ends_with('/'), "seed URL must end with '/': {a}");
// The ctx segment round-trips back to the per-host values.
let ctx = a.trim_end_matches('/').rsplit('/').next().unwrap();
let (mac, host, ip) = decode_seed_ctx(ctx);
assert_eq!(mac.as_deref(), Some("aa:bb"));
assert_eq!(host.as_deref(), Some("u1"));
assert_eq!(ip.as_deref(), Some("10.1.1.5"));
}
#[test]
fn windows_answer_file_is_not_injected() {
let p = DeployProfile {
unattended_file: Some("ks1".into()),
..Default::default()
};
assert!(
build_unattended_args("http://h", &meta(UnattendedKind::AnswerFile), None, &p)
.is_none()
);
}
#[test]
fn seed_ctx_empty_segment_decodes_to_none() {
let ctx = encode_seed_ctx(None, None, None);
let (m, h, i) = decode_seed_ctx(&ctx);
assert!(m.is_none() && h.is_none() && i.is_none());
// Garbage decodes safely to all-None.
let (m2, h2, i2) = decode_seed_ctx("!!!not-base64!!!");
assert!(m2.is_none() && h2.is_none() && i2.is_none());
}
#[test]
fn pct_encode_escapes_reserved() {
assert_eq!(pct_encode("aa:bb cc"), "aa%3Abb%20cc");
assert_eq!(pct_encode("node-7.lab_1~"), "node-7.lab_1~");
}
#[test] #[test]
fn version_newer_detects_updates() { fn version_newer_detects_updates() {
assert!(version_is_newer("0.5.1", "0.5.0")); assert!(version_is_newer("0.5.1", "0.5.0"));
+1 -1
View File
@@ -317,7 +317,7 @@ pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMa
// screens can render the FleetDM-style full-width custom logo (and // screens can render the FleetDM-style full-width custom logo (and
// cache-bust it) without an extra round trip. `/api/me` is public, // cache-bust it) without an extra round trip. `/api/me` is public,
// and the logo asset is public, so this leaks nothing sensitive. // and the logo asset is public, so this leaks nothing sensitive.
let has_custom_logo = state.branding.has_any_web_logo(); let has_custom_logo = state.branding.has_logo();
let logo_rev = state.branding.logo_rev(); let logo_rev = state.branding.logo_rev();
if !state.admin.is_configured() { if !state.admin.is_configured() {
return ( return (
+1 -20
View File
@@ -460,21 +460,8 @@ pub fn render_queue_entry(base_url: &str) -> String {
} }
/// Per-entry boot script (same as Phase 1, with extra_kernel_args appended). /// Per-entry boot script (same as Phase 1, with extra_kernel_args appended).
///
/// `unattended_args` (v0.5.2) carries the per-host unattended-install
/// kernel arguments (`inst.ks=…`, `auto=true … url=…`, or
/// `autoinstall ds=nocloud-net;s=…`) when the requesting MAC has a
/// deployment profile with an answer file selected. It's appended to the
/// Linux kernel command line after the operator's global extra args, and
/// ignored for Windows (wimboot) / sanboot entries which don't take a
/// kernel cmdline.
#[must_use] #[must_use]
pub fn render_entry( pub fn render_entry(entry: &BootEntry, settings: &Settings, base_url: &str) -> String {
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");
@@ -490,12 +477,6 @@ pub fn render_entry(
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}");
+1 -6
View File
@@ -5,7 +5,7 @@ use openpxe_core::{
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus, AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
Metrics, NotifyStore, SettingsStore, SsoStore, Metrics, NotifyStore, SettingsStore, SsoStore,
}; };
use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager, UnattendedStore}; use openpxe_iso_store::{IsoStore, NfsShareManager, SmbManager, SmbShareManager};
use std::sync::Arc; use std::sync::Arc;
use time::OffsetDateTime; use time::OffsetDateTime;
@@ -67,11 +67,6 @@ pub struct AppState {
/// In-process (no subprocess); supports HTTP Range requests on /// In-process (no subprocess); supports HTTP Range requests on
/// NFS-sourced ISOs because NFSv3 READ3 takes an explicit offset. /// NFS-sourced ISOs because NFSv3 READ3 takes an explicit offset.
pub nfs_shares: NfsShareManager, pub nfs_shares: NfsShareManager,
/// v0.5.2: uploaded unattended-install answer files (Kickstart /
/// Preseed / Autoinstall / Windows answer files). Served on demand to
/// booting clients with per-host hostname/IP/MAC templating; lives in
/// its own directory, never the ISO listing or PXE menu.
pub unattended: UnattendedStore,
/// Browser chunked upload state. Multipart uploads still go straight /// Browser chunked upload state. Multipart uploads still go straight
/// through `IsoStore`, but the UI uses sessions so large ISO transfers /// through `IsoStore`, but the UI uses sessions so large ISO transfers
/// can show deterministic progress and leave visible partial files. /// can show deterministic progress and leave visible partial files.
+6 -220
View File
@@ -96,8 +96,6 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
let settings = SettingsStore::load_or_default(dir.path()); let settings = SettingsStore::load_or_default(dir.path());
let smb_shares = SmbShareManager::new(dir.path(), iso_store.clone()); let smb_shares = SmbShareManager::new(dir.path(), iso_store.clone());
let nfs_shares = NfsShareManager::new(dir.path(), iso_store.clone()); let nfs_shares = NfsShareManager::new(dir.path(), iso_store.clone());
let unattended = openpxe_iso_store::UnattendedStore::new(dir.path().join("unattended"));
unattended.ensure_dir().await.unwrap();
let log_bus = LogBus::new(64); let log_bus = LogBus::new(64);
let hosts = HostBindings::load_or_default(dir.path()); let hosts = HostBindings::load_or_default(dir.path());
let boot_log = openpxe_core::BootLog::load_or_default(dir.path()); let boot_log = openpxe_core::BootLog::load_or_default(dir.path());
@@ -124,7 +122,6 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
smb: None, smb: None,
smb_shares, smb_shares,
nfs_shares, nfs_shares,
unattended,
uploads: openpxe_http_api::uploads::UploadSessions::default(), uploads: openpxe_http_api::uploads::UploadSessions::default(),
log_bus, log_bus,
started_at: time::OffsetDateTime::now_utc(), started_at: time::OffsetDateTime::now_utc(),
@@ -1491,8 +1488,7 @@ async fn api_docs_lists_known_endpoints() {
"/api/isos", "/api/isos",
"/api/isos/:id/category", "/api/isos/:id/category",
"/api/storage/disk", "/api/storage/disk",
"/api/branding/logo/:slot", "/api/branding/logo",
"/api/unattended",
"/api/boot-log", "/api/boot-log",
"/metrics", "/metrics",
] { ] {
@@ -1513,7 +1509,7 @@ async fn branding_clear_when_no_logo_is_no_content() {
.oneshot( .oneshot(
Request::builder() Request::builder()
.method("DELETE") .method("DELETE")
.uri("/api/branding/logo/dark") .uri("/api/branding/logo")
.body(Body::empty()) .body(Body::empty())
.unwrap(), .unwrap(),
) )
@@ -1954,7 +1950,6 @@ async fn pxe_background_falls_back_to_default_for_svg_upload() {
state state
.branding .branding
.set_logo( .set_logo(
openpxe_core::LogoSlot::Client,
"image/svg+xml", "image/svg+xml",
"svg", "svg",
br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"/>"#, br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"/>"#,
@@ -1990,10 +1985,7 @@ async fn pxe_logo_composes_to_1024x768_png() {
// the iPXE menu always paints at consistent dimensions. // the iPXE menu always paints at consistent dimensions.
let (state, _dir) = build_state().await; let (state, _dir) = build_state().await;
let png = tiny_png(); let png = tiny_png();
state state.branding.set_logo("image/png", "png", &png).unwrap();
.branding
.set_logo(openpxe_core::LogoSlot::Client, "image/png", "png", &png)
.unwrap();
let app = build_router(state); let app = build_router(state);
let res = app let res = app
.clone() .clone()
@@ -2033,10 +2025,7 @@ async fn pxe_logo_endpoint_is_public_after_admin_setup() {
// auth allowlist gates `/api/*` only. // auth allowlist gates `/api/*` only.
let (state, _dir) = build_state().await; let (state, _dir) = build_state().await;
let png = tiny_png(); let png = tiny_png();
state state.branding.set_logo("image/png", "png", &png).unwrap();
.branding
.set_logo(openpxe_core::LogoSlot::Client, "image/png", "png", &png)
.unwrap();
let app = build_router(state); let app = build_router(state);
// Configure an admin so the middleware kicks in. // Configure an admin so the middleware kicks in.
let (s, _, _) = post_collect( let (s, _, _) = post_collect(
@@ -2051,201 +2040,6 @@ async fn pxe_logo_endpoint_is_public_after_admin_setup() {
assert_eq!(s, StatusCode::OK); assert_eq!(s, StatusCode::OK);
} }
// ─── v0.5.2: unattended files + deployment profiles ─────────────────────────
async fn post_multipart(
router: &axum::Router,
path: &str,
ct: &str,
body: Vec<u8>,
) -> (StatusCode, Vec<u8>) {
let res = router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(path)
.header("content-type", ct)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
let status = res.status();
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap()
.to_vec();
(status, body)
}
#[tokio::test]
async fn unattended_upload_list_serve_and_template() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let ks = b"install\nnetwork --hostname={{HOSTNAME}} --ip={{IP}}\n%packages\n@core\n%end\n";
let (ct, body) = multipart_iso_body("rocky.ks", ks);
let (s, b) = post_multipart(&app, "/api/unattended", &ct, body).await;
assert_eq!(s, StatusCode::CREATED, "{}", String::from_utf8_lossy(&b));
let m: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(m["kind"], "kickstart");
let id = m["id"].as_str().unwrap().to_string();
let (s, b) = get(&app, "/api/unattended").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["files"].as_array().unwrap().len(), 1);
// Public serve substitutes the query tokens.
let (s, b) = get(
&app,
&format!("/unattended/{id}?hostname=node7&ip=10.0.0.7"),
)
.await;
assert_eq!(s, StatusCode::OK);
let text = String::from_utf8_lossy(&b);
assert!(text.contains("--hostname=node7"), "got: {text}");
assert!(text.contains("--ip=10.0.0.7"), "got: {text}");
assert!(!text.contains("{{"), "tokens left unrendered: {text}");
// Delete.
let res = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/unattended/{id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NO_CONTENT);
let (_, b) = get(&app, "/api/unattended").await;
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["files"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn unattended_upload_rejects_bad_type() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (ct, body) = multipart_iso_body("evil.sh", b"#!/bin/sh\n");
let (s, _) = post_multipart(&app, "/api/unattended", &ct, body).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn host_pin_with_unattended_injects_kickstart_arg() {
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
// Upload a Linux ISO → synthesises the `fake-alpine-linux` LinuxKernel entry.
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await;
assert_eq!(s, StatusCode::CREATED);
// Upload a kickstart.
let (ct, body) = multipart_iso_body("ks.ks", b"install\n%packages\n@core\n%end\n");
let (s, b) = post_multipart(&app, "/api/unattended", &ct, body).await;
assert_eq!(s, StatusCode::CREATED);
let ks_id = serde_json::from_slice::<serde_json::Value>(&b).unwrap()["id"]
.as_str()
.unwrap()
.to_string();
// Pin a MAC to the Linux entry with the unattended profile.
let mac = "aa:bb:cc:dd:ee:01";
let pin = format!(
r#"{{"mac":"{mac}","target":"fake-alpine-linux","label":"lab","auto_hostname":"node7","auto_ip":"10.0.0.7","unattended_file":"{ks_id}"}}"#
);
let (s, b) = post_json(&app, "/api/hosts", &pin).await;
assert_eq!(s, StatusCode::CREATED, "{}", String::from_utf8_lossy(&b));
// Boot the entry as that MAC; the kernel line should carry inst.ks=.
let (s, b) = get(&app, &format!("/boot/fake-alpine-linux.ipxe?mac={mac}")).await;
assert_eq!(s, StatusCode::OK);
let script = String::from_utf8_lossy(&b);
assert!(
script.contains("inst.ks="),
"no kickstart arg injected:\n{script}"
);
assert!(
script.contains("hostname=node7"),
"hostname not passed:\n{script}"
);
}
#[tokio::test]
async fn host_pin_rejects_unknown_unattended_file() {
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await;
assert_eq!(s, StatusCode::CREATED);
let pin = r#"{"mac":"aa:bb:cc:dd:ee:02","target":"fake-alpine-linux","unattended_file":"does-not-exist"}"#;
let (s, _) = post_json(&app, "/api/hosts", pin).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn host_pin_rejects_bad_auto_ip() {
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
let (s, _) = post_multipart(&app, "/api/isos", &ct, body).await;
assert_eq!(s, StatusCode::CREATED);
let pin = r#"{"mac":"aa:bb:cc:dd:ee:03","target":"fake-alpine-linux","auto_ip":"not-an-ip"}"#;
let (s, _) = post_json(&app, "/api/hosts", pin).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn per_theme_logo_and_favicon_serve() {
let (state, _dir) = build_state().await;
// Light slot only; dark falls back to it, favicon stays bundled.
let png = tiny_png();
state
.branding
.set_logo(openpxe_core::LogoSlot::Light, "image/png", "png", &png)
.unwrap();
let app = build_router(state);
// Light theme → the uploaded PNG.
let (s, b) = get(&app, "/assets/logo.svg?theme=light").await;
assert_eq!(s, StatusCode::OK);
assert!(b.starts_with(b"\x89PNG"), "light slot should serve the PNG");
// Dark theme → falls back to the light PNG (only slot set).
let (s, b) = get(&app, "/assets/logo.svg?theme=dark").await;
assert_eq!(s, StatusCode::OK);
assert!(
b.starts_with(b"\x89PNG"),
"dark should fall back to light PNG"
);
// Favicon is always the bundled SVG, never the custom raster.
let (s, b) = get(&app, "/assets/favicon.svg").await;
assert_eq!(s, StatusCode::OK);
let txt = String::from_utf8_lossy(&b);
assert!(txt.contains("<svg"), "favicon must be the bundled SVG mark");
}
#[tokio::test]
async fn branding_slot_rejects_unknown_and_client_svg() {
let (state, _dir) = build_state().await;
let app = build_router(state);
// Unknown slot name → 400.
let (ct, body) = multipart_iso_body("logo.png", &tiny_png());
let (s, _) = post_multipart(&app, "/api/branding/logo/sideways", &ct, body).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
// SVG into the client (PXE) slot → 400 (raster-only).
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg"/>"#;
let boundary = "----OpenPxeTestBoundary1234";
let mut b = Vec::new();
b.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
b.extend_from_slice(b"Content-Disposition: form-data; name=\"file\"; filename=\"l.svg\"\r\n");
b.extend_from_slice(b"Content-Type: image/svg+xml\r\n\r\n");
b.extend_from_slice(svg);
b.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
let ct = format!("multipart/form-data; boundary={boundary}");
let (s, _) = post_multipart(&app, "/api/branding/logo/client", &ct, b).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
// ─── v0.5.1: SAML SSO flow ────────────────────────────────────────────────── // ─── v0.5.1: SAML SSO flow ──────────────────────────────────────────────────
// //
// The core crate exhaustively tests signature verification + semantic // The core crate exhaustively tests signature verification + semantic
@@ -2351,16 +2145,8 @@ fn urlencode(s: &str) -> String {
} }
_ => { _ => {
out.push('%'); out.push('%');
out.push( out.push(char::from_digit((b >> 4) as u32, 16).unwrap().to_ascii_uppercase());
char::from_digit((b >> 4) as u32, 16) out.push(char::from_digit((b & 0xf) as u32, 16).unwrap().to_ascii_uppercase());
.unwrap()
.to_ascii_uppercase(),
);
out.push(
char::from_digit((b & 0xf) as u32, 16)
.unwrap()
.to_ascii_uppercase(),
);
} }
} }
} }
+2 -6
View File
@@ -23,7 +23,6 @@ pub mod pxe_logo;
pub mod smb; pub mod smb;
pub mod smb_share; 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};
@@ -41,10 +40,7 @@ pub use smb_share::{SmbAddRequest, SmbShare, SmbShareError, SmbShareManager, Smb
// Range requests because NFSv3 READ3 takes an explicit offset. // Range requests because NFSv3 READ3 takes an explicit offset.
pub use nfs_share::{NfsAddRequest, NfsShare, NfsShareError, NfsShareManager, NfsStream}; pub use nfs_share::{NfsAddRequest, NfsShare, NfsShareError, NfsShareManager, NfsStream};
pub use store::{ pub use store::{
generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore, UploadHandle, generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore,
}; UploadHandle,
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};
-412
View File
@@ -1,412 +0,0 @@
//! 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);
}
}
-11
View File
@@ -99,16 +99,6 @@ 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 queue = DeploymentQueue::new(); let queue = DeploymentQueue::new();
let settings = SettingsStore::load_or_default(&config.paths.work_dir); let settings = SettingsStore::load_or_default(&config.paths.work_dir);
@@ -184,7 +174,6 @@ async fn main() -> anyhow::Result<()> {
smb: Some(smb.clone()), smb: Some(smb.clone()),
smb_shares: smb_shares.clone(), smb_shares: smb_shares.clone(),
nfs_shares: nfs_shares.clone(), nfs_shares: nfs_shares.clone(),
unattended: unattended.clone(),
uploads: openpxe_http_api::uploads::UploadSessions::default(), uploads: openpxe_http_api::uploads::UploadSessions::default(),
log_bus: log_bus.clone(), log_bus: log_bus.clone(),
started_at: time::OffsetDateTime::now_utc(), started_at: time::OffsetDateTime::now_utc(),
-72
View File
@@ -304,14 +304,6 @@ button.ghost { background: transparent; color: var(--fg); border: 1px solid var(
button.ghost:hover { background: var(--bg-panel-2); color: var(--fg); } button.ghost:hover { background: var(--bg-panel-2); color: var(--fg); }
button.danger { background: transparent; color: var(--err); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); } button.danger { background: transparent; color: var(--err); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); }
button.danger:hover { background: color-mix(in srgb, var(--err) 14%, transparent); color: var(--err); } button.danger:hover { background: color-mix(in srgb, var(--err) 14%, transparent); color: var(--err); }
/* v0.5.3: unified spacing for a card's primary action button(s). Any
button that sits as a direct child of a card body (Save, Bind, Add,
Launch, …) gets the same gap above it so it never butts against the
form. Inline buttons inside table rows / toolbars / logo slots /
modal action bars are nested deeper, so the `>` keeps them untouched.
Adjacent action buttons on one row (e.g. Save + Send test) share the
margin and stay aligned. */
.card .body > button { margin-top: 16px; }
label.field { label.field {
display: grid; gap: 4px; margin-bottom: 14px; display: grid; gap: 4px; margin-bottom: 14px;
@@ -848,67 +840,3 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
border: 1px solid var(--border); border: 1px solid var(--border);
color: var(--fg-dim); 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; }
+124 -366
View File
@@ -175,83 +175,6 @@
return { ok: true }; return { ok: true };
} }
// v0.5.2: pretty label for an unattended file's detected kind.
function unattendedKindLabel(k) {
return ({
kickstart: 'Kickstart', preseed: 'Preseed', autoinstall: 'Autoinstall',
answer_file: 'Answer file', unknown: 'Unknown',
})[k] || (k || 'Unknown');
}
// v0.5.2: build the shared "deployment profile" field group — auto
// hostname, auto IP, and an unattended-file picker — reused by the
// Hosts pin form and the Queue "Profile" modal. `files` is the
// /api/unattended list; `profile` seeds the current values. Returns the
// wrapper element plus a `read()` that yields the API body shape.
function buildProfileFields(profile, files, layoutClass) {
profile = profile || {};
files = files || [];
const hostnameInput = el('input', {type:'text', spellcheck:'false',
placeholder:'e.g. node-7', value: profile.auto_hostname || ''});
const ipInput = el('input', {type:'text', spellcheck:'false',
placeholder:'e.g. 10.0.0.7', value: profile.auto_ip || ''});
const sel = el('select', {},
[el('option', {value:''}, '— none —')].concat(
files.map(f => el('option', {value: f.id},
f.filename + ' · ' + unattendedKindLabel(f.kind)))));
sel.value = profile.unattended_file || '';
const wrap = el('div', {class: layoutClass || 'form-row cols-3'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Auto hostname (optional)'), hostnameInput]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Auto IP address (optional)'), ipInput]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Unattended file'), sel]),
]);
return {
wrap,
read() {
return {
auto_hostname: hostnameInput.value.trim() || null,
auto_ip: ipInput.value.trim() || null,
unattended_file: sel.value || null,
};
},
};
}
// v0.5.2: minimal modal overlay. `onSave(msgEl)` runs on Save and may
// return a falsy value to keep the modal open (e.g. on validation
// error) or anything truthy to close it.
function openModal(titleText, contentEls, onSave) {
const overlay = el('div', {class:'modal-overlay'});
const close = () => { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); };
const msg = el('div', {class:'msg', style:'margin-top:10px'});
const cancelBtn = el('button', {class:'ghost', type:'button', onclick: close}, 'Cancel');
const saveBtn = el('button', {class:'submit', type:'button'}, 'Save');
saveBtn.onclick = async () => {
saveBtn.disabled = true;
try {
const ok = await onSave(msg);
if (ok) close();
} finally {
saveBtn.disabled = false;
}
};
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
document.addEventListener('keydown', function esc(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', esc); }
});
overlay.appendChild(el('div', {class:'modal-box'}, [
el('h2', {}, titleText),
...(Array.isArray(contentEls) ? contentEls : [contentEls]),
msg,
el('div', {class:'modal-actions'}, [cancelBtn, saveBtn]),
]));
document.body.appendChild(overlay);
return { close };
}
// ── views ──────────────────────────────────────────────────────── // ── views ────────────────────────────────────────────────────────
const views = { const views = {
dashboard: async () => { dashboard: async () => {
@@ -396,11 +319,9 @@
}, },
queue: async () => { queue: async () => {
const [{ entries = [] }, isos, unattRes] = await Promise.all([ const [{ entries = [] }, isos] = await Promise.all([
getJSON('/api/queue'), getJSON('/api/isos'), getJSON('/api/queue'), getJSON('/api/isos'),
getJSON('/api/unattended').catch(() => ({ files: [] })),
]); ]);
const unattendedFiles = unattRes.files || [];
const targets = isos.flatMap(i => i.boot_entries.map(e => ({ const targets = isos.flatMap(i => i.boot_entries.map(e => ({
id: e.id, title: e.title + ' — ' + familyLabel(i.introspection.family) id: e.id, title: e.title + ' — ' + familyLabel(i.introspection.family)
}))); })));
@@ -423,51 +344,21 @@
const track = entries.length const track = entries.length
? el('div', {class:'queue-track'}, ? el('div', {class:'queue-track'},
entries.map(g => { entries.map(g => el('div', {class:'queue-row' + (g.assigned_target ? ' assigned' : '')}, [
const prof = g.profile || {}; el('div', {class:'pos'}, '#' + g.position),
const hasProfile = prof.auto_hostname || prof.auto_ip || prof.unattended_file; el('div', {}, [
const profSummary = hasProfile el('div', {class:'mac'}, g.mac),
? el('div', {class:'meta', style:'margin-top:2px'}, el('div', {class:'meta'},
'⚙ ' + [ (g.ip ? String(g.ip) + ' · ' : '') + archLabel(g.arch) + ' · joined ' + fmtAgo(g.joined_at)),
prof.auto_hostname ? 'host ' + prof.auto_hostname : null, ]),
prof.auto_ip ? 'ip ' + prof.auto_ip : null, el('div', {}, g.assigned_target
prof.unattended_file ? 'unattended: ' + prof.unattended_file : null, ? el('span', {class:'tag ok'}, '→ ' + g.assigned_target)
].filter(Boolean).join(' · ')) : el('span', {class:'tag accent'}, 'waiting')),
: null; el('button', {class:'ghost', onclick: async () => {
return el('div', {class:'queue-row' + (g.assigned_target ? ' assigned' : '')}, [ await fetch('/api/queue/' + encodeURIComponent(g.id), {method:'DELETE'});
el('div', {class:'pos'}, '#' + g.position), render('queue');
el('div', {}, [ }}, 'Release'),
el('div', {class:'mac'}, g.mac), ]))
el('div', {class:'meta'},
(g.ip ? String(g.ip) + ' · ' : '') + archLabel(g.arch) + ' · joined ' + fmtAgo(g.joined_at)),
profSummary,
]),
el('div', {}, g.assigned_target
? el('span', {class:'tag ok'}, '→ ' + g.assigned_target)
: el('span', {class:'tag accent'}, 'waiting')),
// v0.5.2: per-device deployment profile (auto hostname/IP +
// unattended file), same fields as a Hosts pin.
el('button', {class: hasProfile ? 'accent' : 'ghost', onclick: () => {
const fields = buildProfileFields(prof, unattendedFiles, 'form-row');
openModal('Deployment profile · ' + g.mac, [
el('p', {class:'msg', style:'margin-bottom:12px'},
'On assignment this device boots with the chosen unattended ' +
'file; {{HOSTNAME}}/{{IP}}/{{MAC}} are filled into the answer file.'),
fields.wrap,
], async (msg) => {
const r = await putJSON('/api/queue/' + encodeURIComponent(g.id) + '/profile', fields.read());
if (r.ok) { render('queue'); return true; }
msg.textContent = 'Save failed: ' + (await r.text());
msg.className = 'msg err';
return false;
});
}}, 'Profile'),
el('button', {class:'ghost', onclick: async () => {
await fetch('/api/queue/' + encodeURIComponent(g.id), {method:'DELETE'});
render('queue');
}}, 'Release'),
]);
})
) )
: el('div', {class:'empty'}, : el('div', {class:'empty'},
'No clients queued. Boot a client and choose "Queued Deployment" in the PXE menu.'); 'No clients queued. Boot a client and choose "Queued Deployment" in the PXE menu.');
@@ -508,18 +399,16 @@
// v0.4.67: NFSv3 added back as an in-process Rust client // v0.4.67: NFSv3 added back as an in-process Rust client
// (nfs3_client crate). Both protocols available side-by-side; // (nfs3_client crate). Both protocols available side-by-side;
// operators pick whichever their NAS prefers. // operators pick whichever their NAS prefers.
const [isos, settings, smbRes, nfsRes, disk, unattRes] = await Promise.all([ const [isos, settings, smbRes, nfsRes, disk] = await Promise.all([
getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/isos'), getJSON('/api/settings'),
getJSON('/api/smb-shares'), getJSON('/api/smb-shares'),
getJSON('/api/nfs-shares'), getJSON('/api/nfs-shares'),
getJSON('/api/storage/disk').catch(() => ({ getJSON('/api/storage/disk').catch(() => ({
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?', total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
})), })),
getJSON('/api/unattended').catch(() => ({ files: [] })),
]); ]);
const shares = smbRes.shares || []; const shares = smbRes.shares || [];
const nfsShares = nfsRes.shares || []; const nfsShares = nfsRes.shares || [];
const unattendedFiles = unattRes.files || [];
// ── Upload card ── // ── Upload card ──
const drop = el('div', {class:'drop', id:'drop'}, [ const drop = el('div', {class:'drop', id:'drop'}, [
@@ -823,12 +712,10 @@
shareMsg.className = 'msg err'; shareMsg.className = 'msg err';
}; };
// Protocol picker — swaps which field block is visible. v0.5.2: // Protocol picker — swaps which field block is visible.
// NFS is the default (listed first) — it has no credential fields,
// so the form lands cleaner than the SMB guest/user/password row.
const protoSelect = el('select', {}, [ const protoSelect = el('select', {}, [
el('option', {value:'nfs'}, 'NFS (NFSv3)'),
el('option', {value:'smb'}, 'SMB / CIFS'), el('option', {value:'smb'}, 'SMB / CIFS'),
el('option', {value:'nfs'}, 'NFS (NFSv3)'),
]); ]);
// SMB inputs. // SMB inputs.
@@ -904,7 +791,7 @@
protoSelect.addEventListener('change', syncProto); protoSelect.addEventListener('change', syncProto);
// One add button; dispatches to the selected protocol's endpoint. // One add button; dispatches to the selected protocol's endpoint.
const addShare = el('button', {onclick: async () => { const addShare = el('button', {style:'margin-top:14px', onclick: async () => {
if (protoSelect.value === 'smb') { if (protoSelect.value === 'smb') {
if (!smbServer.value || !smbShare.value) { if (!smbServer.value || !smbShare.value) {
shareMsg.replaceChildren(document.createTextNode('Server and share name are required.')); shareMsg.replaceChildren(document.createTextNode('Server and share name are required.'));
@@ -970,86 +857,7 @@
const diskCard = diskSpaceCard(disk); const diskCard = diskSpaceCard(disk);
// ── Advanced: unattended answer-file upload (v0.5.2) ── return el('div', {class:'grid'}, [
// Mirrors the Settings "Advanced" disclosure. Kickstart / Preseed /
// Autoinstall / Windows answer files land in their own directory
// (never the ISO listing or PXE menu) and are referenced by host
// pins + queue profiles.
const unattMsg = el('div', {class:'msg', style:'margin-top:10px'});
const unattFile = el('input', {
type:'file',
accept:'.ks,.cfg,.seed,.yaml,.yml,.xml',
style:'display:none', id:'unatt-file',
});
async function uploadUnattended(f) {
const fd = new FormData(); fd.append('file', f, f.name);
unattMsg.textContent = 'Uploading ' + f.name + ' (' + fmtBytes(f.size) + ')…';
unattMsg.className = 'msg';
const r = await fetch('/api/unattended', {method:'POST', body: fd});
if (r.ok) {
unattMsg.textContent = 'Stored ' + f.name + '.';
unattMsg.className = 'msg ok';
render('storage');
} else {
unattMsg.textContent = 'Upload failed: ' + (await r.text());
unattMsg.className = 'msg err';
}
}
const unattDrop = el('div', {class:'drop', id:'unatt-drop'}, [
el('div', {}, 'Drop a Kickstart, Preseed, Autoinstall, or Answer File here.'),
el('div', {style:'font-size:12px;margin-top:6px'},
'Accepted: .ks · .cfg · .seed · .yaml · .yml · .xml (or user-data). ' +
'Use {{HOSTNAME}}, {{IP}}, {{MAC}} as placeholders — they are filled in per host at boot.'),
]);
unattDrop.onclick = () => unattFile.click();
unattDrop.addEventListener('dragover', e => { e.preventDefault(); unattDrop.classList.add('hover'); });
unattDrop.addEventListener('dragleave', () => unattDrop.classList.remove('hover'));
unattDrop.addEventListener('drop', e => {
e.preventDefault(); unattDrop.classList.remove('hover');
if (e.dataTransfer.files[0]) uploadUnattended(e.dataTransfer.files[0]);
});
unattFile.onchange = () => { if (unattFile.files[0]) uploadUnattended(unattFile.files[0]); };
const unattRows = unattendedFiles.length
? unattendedFiles.map(f => el('div', {class:'nfs-row'}, [
el('span', {class:'dot ok'}),
el('div', {}, [
el('div', {class:'id'}, [
el('span', {class:'proto-badge'}, unattendedKindLabel(f.kind)),
document.createTextNode(f.filename),
]),
el('div', {class:'meta'}, fmtBytes(f.size_bytes) + ' · id ' + f.id),
]),
el('span'),
el('button', {class:'danger', onclick: async () => {
if (!confirm('Delete unattended file ' + f.filename + '?')) return;
await fetch('/api/unattended/' + encodeURIComponent(f.id), {method:'DELETE'});
render('storage');
}}, 'Delete'),
el('span'),
]))
: [el('div', {class:'empty'}, 'No unattended files yet.')];
const unattendedAdvanced = el('details', {class:'advanced-disclosure', style:'margin-top:18px'}, [
el('summary', {class:'advanced-summary'}, 'Advanced'),
el('div', {class:'card', style:'margin-top:14px'}, [
el('header', {}, [
el('h2', {}, 'Unattended file upload'),
el('span', {class:'sub'}, unattendedFiles.length + ' file' + (unattendedFiles.length === 1 ? '' : 's')),
]),
el('div', {class:'body'}, [
unattDrop, unattFile, unattMsg,
el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows),
el('p', {class:'msg', style:'margin-top:14px'},
'These answer files drive unattended installs. Attach one to a ' +
'host pin (Hosts tab) or a queued device (Queue → Profile); on ' +
'boot OpenPXE injects the matching kernel argument and serves the ' +
'file with the hosts name/IP filled in. Stored separately from ISOs.'),
]),
]),
]);
return el('div', {}, [el('div', {class:'grid'}, [
diskCard, diskCard,
el('div', {class:'card'}, [ el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Upload ISO')), el('header', {}, el('h2', {}, 'Upload ISO')),
@@ -1075,9 +883,16 @@
addShare, shareMsg, addShare, shareMsg,
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, remoteRows), el('div', {style:'margin-top:18px;display:grid;gap:8px'}, remoteRows),
el('p', {class:'msg', style:'margin-top:14px'}, el('p', {class:'msg', style:'margin-top:14px'},
'Remote .iso libraries are read on demand — no local cache to ' + 'Remote ISO libraries are read on demand — no local cache, no ' +
'preserve disk usage. Support for NFS 3.0 and SMB. Ensure that ' + 'double disk usage. SMB/CIFS is read in userspace via Sambas ' +
'the hosts IP address is provisioned.'), 'smbclient; NFSv3 via a pure-Rust in-process client. Both work in ' +
'any container (Unraid, OpenShift restricted SCC, plain Docker) with ' +
'no kernel modules and no CAP_SYS_ADMIN. SMB supports guest or ' +
'user/password; most NAS appliances expose ISO libraries as ' +
'guest-readable. NFSv3 auth is AUTH_SYS only — gate access by ' +
'allowing this OpenPXE hosts IP in the servers export list. ' +
'NFS-sourced ISOs also support HTTP Range (seek into a 5 GB ISO ' +
'without reading what precedes the offset); SMB streams sequentially.'),
]), ]),
]), ]),
el('div', {class:'card'}, [ el('div', {class:'card'}, [
@@ -1087,17 +902,15 @@
]), ]),
isoTable, isoTable,
]), ]),
]), unattendedAdvanced]); ]);
}, },
hosts: async () => { hosts: async () => {
const [{ hosts = [] }, isos, bootLogRes, unattRes] = await Promise.all([ const [{ hosts = [] }, isos, bootLogRes] = await Promise.all([
getJSON('/api/hosts'), getJSON('/api/isos'), getJSON('/api/hosts'), getJSON('/api/isos'),
getJSON('/api/boot-log').catch(() => ({ events: [] })), getJSON('/api/boot-log').catch(() => ({ events: [] })),
getJSON('/api/unattended').catch(() => ({ files: [] })),
]); ]);
const bootEvents = bootLogRes.events || []; const bootEvents = bootLogRes.events || [];
const unattendedFiles = unattRes.files || [];
const targets = isos.flatMap(i => i.boot_entries.map(e => ({ const targets = isos.flatMap(i => i.boot_entries.map(e => ({
id: e.id, title: e.title + ' — ' + familyLabel(i.introspection.family), id: e.id, title: e.title + ' — ' + familyLabel(i.introspection.family),
}))); })));
@@ -1116,20 +929,14 @@
.concat(reserved.map(t => el('option', {value: t.id}, t.title))) .concat(reserved.map(t => el('option', {value: t.id}, t.title)))
.concat(targets.map(t => el('option', {value: t.id}, t.title)))); .concat(targets.map(t => el('option', {value: t.id}, t.title))));
const msg = el('div', {class:'msg'}); const msg = el('div', {class:'msg'});
// v0.5.2: optional unattended-install profile — auto hostname, auto
// IP, and an answer-file picker. On boot, a bound MAC with an
// unattended file selected has the right kernel arg injected
// (inst.ks / preseed url / autoinstall ds=nocloud) and the
// hostname/IP templated into the served answer file.
const profileFields = buildProfileFields({}, unattendedFiles, 'form-row cols-3');
const upsertBtn = el('button', {onclick: async () => { const upsertBtn = el('button', {onclick: async () => {
if (!macInput.value || !targetSel.value) { if (!macInput.value || !targetSel.value) {
msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return; msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
} }
const r = await postJSON('/api/hosts', Object.assign({ const r = await postJSON('/api/hosts', {
mac: macInput.value, target: targetSel.value, label: labelInput.value, mac: macInput.value, target: targetSel.value, label: labelInput.value,
}, profileFields.read())); });
if (r.ok) { if (r.ok) {
msg.textContent = 'Saved.'; msg.className = 'msg ok'; msg.textContent = 'Saved.'; msg.className = 'msg ok';
render('hosts'); render('hosts');
@@ -1155,18 +962,10 @@
} }
setTimeout(() => { wakeBtn.textContent = original; wakeBtn.disabled = false; }, 2500); setTimeout(() => { wakeBtn.textContent = original; wakeBtn.disabled = false; }, 2500);
}}, 'Wake'); }}, 'Wake');
const autoDeploy = (h.auto_hostname || h.auto_ip || h.unattended_file)
? el('div', {style:'font-size:12px;line-height:1.5'}, [
h.unattended_file ? el('div', {}, [el('span', {class:'tag accent'}, 'unattended'), document.createTextNode(' ' + h.unattended_file)]) : null,
h.auto_hostname ? el('div', {class:'mono'}, 'host: ' + h.auto_hostname) : null,
h.auto_ip ? el('div', {class:'mono'}, 'ip: ' + h.auto_ip) : null,
])
: el('span', {class:'tag'}, '—');
return el('tr', {}, [ return el('tr', {}, [
el('td', {class:'mono'}, h.mac), el('td', {class:'mono'}, h.mac),
el('td', {}, h.label || el('span', {class:'tag'}, '(unlabeled)')), el('td', {}, h.label || el('span', {class:'tag'}, '(unlabeled)')),
el('td', {class:'mono'}, h.target), el('td', {class:'mono'}, h.target),
el('td', {}, autoDeploy),
el('td', {}, fmtAgo(h.updated_at)), el('td', {}, fmtAgo(h.updated_at)),
el('td', {style:'text-align:right;white-space:nowrap'}, [ el('td', {style:'text-align:right;white-space:nowrap'}, [
wakeBtn, wakeBtn,
@@ -1183,8 +982,7 @@
? el('table', {}, [ ? el('table', {}, [
el('thead', {}, el('tr', {}, [ el('thead', {}, el('tr', {}, [
el('th',{},'MAC'), el('th',{},'Label'), el('th',{},'MAC'), el('th',{},'Label'),
el('th',{},'Target'), el('th',{},'Auto-deploy'), el('th',{},'Target'), el('th',{},'Updated'), el('th',{},''),
el('th',{},'Updated'), el('th',{},''),
])), ])),
el('tbody', {}, rows), el('tbody', {}, rows),
]) ])
@@ -1204,13 +1002,10 @@
'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'), 'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'),
]), ]),
]), ]),
el('div', {style:'margin-top:16px'}, profileFields.wrap),
upsertBtn, msg, upsertBtn, msg,
el('p', {class:'msg', style:'margin-top:14px'}, el('p', {class:'msg', style:'margin-top:14px'},
'When a client with a bound MAC requests boot.ipxe, OpenPXE ' + 'When a client with a bound MAC requests boot.ipxe, OpenPXE ' +
'short-circuits past the interactive menu and chains directly. ' + 'short-circuits past the interactive menu and chains directly.'),
'If an unattended file is selected, the matching kernel argument ' +
'is injected and the hostname/IP are templated into the answer file.'),
]), ]),
]), ]),
el('div', {class:'card'}, [ el('div', {class:'card'}, [
@@ -1393,6 +1188,7 @@
getJSON('/api/notify').catch(() => ({ enabled:false, kind:'slack' })), getJSON('/api/notify').catch(() => ({ enabled:false, kind:'slack' })),
getJSON('/api/docs').catch(() => ({ groups: [] })), getJSON('/api/docs').catch(() => ({ groups: [] })),
]); ]);
const hasLogo = !!status.custom_logo;
// ── Account card (Forms admin credentials, v0.4.5). // ── Account card (Forms admin credentials, v0.4.5).
// Sonarr/Radarr-style: the admin enters their current password // Sonarr/Radarr-style: the admin enters their current password
@@ -1411,7 +1207,7 @@
// beneath the input row instead of butting against the password // beneath the input row instead of butting against the password
// fields. Mirrors the `Save SSO settings` button below for visual // fields. Mirrors the `Save SSO settings` button below for visual
// parity between the two settings cards. // parity between the two settings cards.
const accountSave = el('button', {onclick: async () => { const accountSave = el('button', {style:'margin-top:6px', onclick: async () => {
accountMsg.textContent = ''; accountMsg.className = 'msg'; accountMsg.textContent = ''; accountMsg.className = 'msg';
if (!currentPw.value) { if (!currentPw.value) {
accountMsg.textContent = 'Current password is required.'; accountMsg.textContent = 'Current password is required.';
@@ -1536,8 +1332,7 @@
// Hint that used to live under the URL field; surfaced once below // Hint that used to live under the URL field; surfaced once below
// the whole row so it doesn't compete with the in-grid layout. // the whole row so it doesn't compete with the in-grid layout.
const urlHint = el('p', {class:'msg', style:'margin-top:10px;margin-bottom:0'}, const urlHint = el('p', {class:'msg', style:'margin-top:10px;margin-bottom:0'},
'OpenPXE fetches this metadata URL at sign-in to verify the IdPs signature. ' + 'OpenPXE will fetch the metadata URL once SSO sign-in lands; v0.4.63 stores it.');
'Any IdP-authenticated user gets an operator session.');
const refreshSsoFields = () => { const refreshSsoFields = () => {
if (ssoMode.value === 'url') { if (ssoMode.value === 'url') {
urlWrap.style.display = ''; xmlWrap.style.display = 'none'; urlWrap.style.display = ''; xmlWrap.style.display = 'none';
@@ -1549,7 +1344,7 @@
}; };
ssoMode.onchange = refreshSsoFields; ssoMode.onchange = refreshSsoFields;
const ssoMsg = el('div', {class:'msg', style:'margin-top:8px'}); const ssoMsg = el('div', {class:'msg', style:'margin-top:8px'});
const ssoSave = el('button', {onclick: async () => { const ssoSave = el('button', {style:'margin-top:16px', onclick: async () => {
ssoMsg.textContent = ''; ssoMsg.className = 'msg'; ssoMsg.textContent = ''; ssoMsg.className = 'msg';
const payload = { const payload = {
enabled: ssoEnabled.checked, enabled: ssoEnabled.checked,
@@ -1561,7 +1356,7 @@
const r = await putJSON('/api/sso', payload); const r = await putJSON('/api/sso', payload);
if (r.ok) { if (r.ok) {
ssoMsg.textContent = ssoEnabled.checked ssoMsg.textContent = ssoEnabled.checked
? 'SSO saved and live. The login page now shows a “Sign in with …” button.' ? 'SSO configuration saved. Runtime sign-in flow ships in a future release.'
: 'SSO configuration saved (disabled).'; : 'SSO configuration saved (disabled).';
ssoMsg.className = 'msg ok'; ssoMsg.className = 'msg ok';
} else { } else {
@@ -1576,17 +1371,16 @@
el('span', {class:'sub'}, el('span', {class:'sub'},
sso.enabled sso.enabled
? (sso.metadata_url || sso.metadata ? (sso.metadata_url || sso.metadata
? 'live · active' ? 'configured · runtime pending'
: 'enabled but missing source') : 'enabled but missing source')
: 'disabled'), : 'disabled'),
]), ]),
el('div', {class:'body'}, [ el('div', {class:'body'}, [
el('p', {class:'msg', style:'margin-bottom:14px'}, el('p', {class:'msg', style:'margin-bottom:14px'},
'SAML single sign-on is live. With it enabled, the login page shows ' + 'Configure your SAML IdP today; OpenPXE persists the metadata so ' +
'a “Sign in with …” button that hands off to your IdP; OpenPXE ' + 'when SSO sign-in lights up in a future release, no operator ' +
'verifies the signed assertion against the IdP metadata and mints an ' + 're-entry is needed. The local administrator account above is ' +
'operator session for any authenticated user. The local administrator ' + 'always available as a fallback owner regardless of SSO state.'),
'account above always remains available as a fallback.'),
el('label', {class:'check', style:'margin-bottom:14px;max-width:280px'}, [ el('label', {class:'check', style:'margin-bottom:14px;max-width:280px'}, [
ssoEnabled, ssoEnabled,
el('span', {}, 'Enable single sign-on'), el('span', {}, 'Enable single sign-on'),
@@ -1621,83 +1415,72 @@
// referenced by `refreshSsoFields` are attached. // referenced by `refreshSsoFields` are attached.
refreshSsoFields(); refreshSsoFields();
// ── Custom logos (v0.5.2). Three independent slots on one row, // ── Custom logo upload.
// FleetDM-style: Light + Dark feed the WebUI top-left and the form // Single-file drop-zone; PNG/SVG/JPEG/WebP/GIF up to 2 MB.
// login page (whichever theme is active picks its variant); Client // Persisted as <work_dir>/branding/logo.<ext> and served from
// is the raster painted above the PXE boot menu. Each slot has a // /assets/logo.svg in preference to the bundled mark.
// preview, an upload (PNG/SVG/JPEG/WebP/GIF up to 2 MB; the Client const logoFile = el('input', {
// slot is raster-only), and a clear. type:'file',
accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif',
style:'display:none', id:'logo-file',
});
const logoMsg = el('div', {class:'msg', style:'margin-top:10px'}); const logoMsg = el('div', {class:'msg', style:'margin-top:10px'});
const bust = '?v=' + Date.now(); // bust the preview cache after a change const logoBust = '?v=' + Date.now(); // bust the browser cache after upload
const brandingPresence = status.branding || { light:false, dark:false, client:false }; logoFile.onchange = async () => {
// Each swatch previews on a background matching where the mark if (!logoFile.files[0]) return;
// lands (light page / dark page / dark PXE screen), independent of const f = logoFile.files[0];
// the operator's current page theme — so the Dark slot always reads const fd = new FormData(); fd.append('file', f, f.name);
// as dark even while viewing Settings in light mode. logoMsg.textContent = 'Uploading ' + f.name + ' (' + fmtBytes(f.size) + ')…';
const slotDefs = [ logoMsg.className = 'msg';
{ slot:'light', title:'Light mode', preview:'/assets/logo.svg?theme=light' + '&' + bust.slice(1), const r = await fetch('/api/branding/logo', {method:'POST', body: fd});
swatchBg:'#f4f5f7', hint:'Shown on light-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' }, if (r.ok) {
{ slot:'dark', title:'Dark mode', preview:'/assets/logo.svg?theme=dark' + '&' + bust.slice(1), logoMsg.textContent = 'Custom logo installed. Reloading…';
swatchBg:'#0e1014', hint:'Shown on dark-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' }, logoMsg.className = 'msg ok';
{ slot:'client', title:'Client', preview:'/branding/pxe-logo' + bust, setTimeout(() => location.reload(), 600);
swatchBg:'#0e1014', hint:'Above the PXE boot menu.', accept:'image/png,image/jpeg,image/webp,image/gif' }, } else {
]; const t = await r.text();
const slotCol = (def) => { logoMsg.textContent = 'Upload failed: ' + t;
const set = !!brandingPresence[def.slot]; logoMsg.className = 'msg err';
const input = el('input', {type:'file', accept:def.accept, style:'display:none'}); }
input.onchange = async () => {
if (!input.files[0]) return;
const f = input.files[0];
const fd = new FormData(); fd.append('file', f, f.name);
logoMsg.textContent = 'Uploading ' + def.title + ' logo (' + fmtBytes(f.size) + ')…';
logoMsg.className = 'msg';
const r = await fetch('/api/branding/logo/' + def.slot, {method:'POST', body: fd});
if (r.ok) {
logoMsg.textContent = def.title + ' logo installed. Reloading…';
logoMsg.className = 'msg ok';
setTimeout(() => location.reload(), 600);
} else {
logoMsg.textContent = 'Upload failed: ' + (await r.text());
logoMsg.className = 'msg err';
}
};
return el('div', {class:'logo-slot'}, [
el('div', {class:'logo-slot-head'}, [
el('span', {class:'name'}, def.title),
set ? el('span', {class:'tag ok'}, 'set') : el('span', {class:'tag'}, 'default'),
]),
el('div', {class:'swatch', style:'background:' + def.swatchBg},
el('img', {src: def.preview, alt: def.title + ' logo'})),
el('div', {class:'logo-slot-hint'}, def.hint),
el('div', {style:'display:flex;gap:6px;flex-wrap:wrap'}, [
el('button', {class:'ghost', onclick: () => input.click()}, set ? 'Replace' : 'Upload'),
set ? el('button', {class:'danger', onclick: async () => {
if (!confirm('Remove the ' + def.title + ' logo?')) return;
const r = await fetch('/api/branding/logo/' + def.slot, {method:'DELETE'});
if (r.ok || r.status === 204) {
logoMsg.textContent = def.title + ' logo cleared. Reloading…';
logoMsg.className = 'msg ok';
setTimeout(() => location.reload(), 500);
} else {
logoMsg.textContent = 'Clear failed: ' + (await r.text());
logoMsg.className = 'msg err';
}
}}, 'Remove') : null,
]),
input,
]);
}; };
const logoCard = el('div', {class:'card'}, [ const logoCard = el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Branding')), el('header', {}, el('h2', {}, 'Branding')),
el('div', {class:'body'}, [ el('div', {class:'body'}, [
el('p', {class:'msg', style:'margin-bottom:14px'}, el('p', {class:'msg', style:'margin-bottom:14px'},
'Upload your own brand marks. Up to 2 MB each; PNG, SVG, JPEG, ' + 'Override the top-left brand mark with your own logo. Up to 2 MB; ' +
'WebP, or GIF (the Client logo must be a raster). The Light and Dark ' + 'PNG, SVG, JPEG, WebP, or GIF. The original OpenPXE version is ' +
'marks appear in the top-left and on the sign-in page depending on ' + 'always shown in the bottom-left for support purposes.'),
'theme; the Client mark sits above the PXE boot menu. The favicon ' + el('div', {class:'logo-preview'}, [
'and the version string in the bottom-left always stay OpenPXE.'), el('div', {class:'swatch'},
el('div', {class:'logo-slots'}, slotDefs.map(slotCol)), el('img', {src: '/assets/logo.svg' + logoBust, alt:'current logo'})),
logoMsg, el('div', {class:'info'}, [
el('div', {class:'name'}, hasLogo ? 'Custom logo (uploaded)' : 'Default OpenPXE mark'),
el('div', {class:'meta'},
hasLogo
? 'Operator-uploaded; served from <work_dir>/branding/.'
: 'Bundled rainbow-horizon mark. Upload an image to override.'),
]),
el('div', {style:'display:flex;gap:8px;flex-wrap:wrap'}, [
el('button', {onclick: () => logoFile.click()},
hasLogo ? 'Replace logo' : 'Upload logo'),
hasLogo
? el('button', {class:'danger', onclick: async () => {
if (!confirm('Remove custom logo and revert to the OpenPXE mark?')) return;
const r = await fetch('/api/branding/logo', {method:'DELETE'});
if (r.ok || r.status === 204) {
logoMsg.textContent = 'Reverted to default mark. Reloading…';
logoMsg.className = 'msg ok';
setTimeout(() => location.reload(), 500);
} else {
const t = await r.text();
logoMsg.textContent = 'Clear failed: ' + t;
logoMsg.className = 'msg err';
}
}}, 'Remove')
: null,
]),
]),
logoFile, logoMsg,
]), ]),
]); ]);
@@ -1799,13 +1582,13 @@
smtp_implicit_tls: sTls.checked, smtp_implicit_tls: sTls.checked,
}); });
const saveBtn = el('button', {onclick: async () => { const saveBtn = el('button', {style:'margin-top:16px', onclick: async () => {
nMsg.textContent = 'Saving…'; nMsg.className = 'msg'; nMsg.textContent = 'Saving…'; nMsg.className = 'msg';
const r = await putJSON('/api/notify', collectNotify()); const r = await putJSON('/api/notify', collectNotify());
if (r.ok) { nMsg.textContent = 'Saved.'; nMsg.className = 'msg ok'; render('settings'); } if (r.ok) { nMsg.textContent = 'Saved.'; nMsg.className = 'msg ok'; render('settings'); }
else { nMsg.textContent = 'Save failed: ' + (await r.text()); nMsg.className = 'msg err'; } else { nMsg.textContent = 'Save failed: ' + (await r.text()); nMsg.className = 'msg err'; }
}}, 'Save notification settings'); }}, 'Save notification settings');
const testBtn = el('button', {class:'ghost', style:'margin-left:8px', const testBtn = el('button', {class:'ghost', style:'margin-top:16px;margin-left:8px',
onclick: async () => { onclick: async () => {
nMsg.textContent = 'Sending test…'; nMsg.className = 'msg'; nMsg.textContent = 'Sending test…'; nMsg.className = 'msg';
// Save first so the test uses exactly what's on screen. // Save first so the test uses exactly what's on screen.
@@ -1980,23 +1763,11 @@
function applyTheme(theme) { function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('openpxe-theme', theme); } catch {} try { localStorage.setItem('openpxe-theme', theme); } catch {}
applyBrandLogos(theme);
} }
function currentTheme() { function currentTheme() {
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark'; return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
} }
// v0.5.2: point the sidebar + login brand marks at the theme's logo
// slot so a light/dark toggle swaps the logo too (FleetDM-style).
function applyBrandLogos(theme) {
theme = theme || currentTheme();
const rev = (brandInfo && brandInfo.logo_rev) || 0;
const url = '/assets/logo.svg?theme=' + theme + '&r=' + rev;
document.querySelectorAll('.sidebar .brand img, .brand-row img').forEach(img => {
img.src = url;
});
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
applyBrandLogos();
const btn = $('#theme-toggle'); const btn = $('#theme-toggle');
if (btn) { if (btn) {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
@@ -2097,7 +1868,7 @@
// logo spans the card, no "OpenPXE" wordmark (the logo is the brand). // logo spans the card, no "OpenPXE" wordmark (the logo is the brand).
// Default: bundled mark + "OpenPXE". // Default: bundled mark + "OpenPXE".
function authBrandRow() { function authBrandRow() {
const src = '/assets/logo.svg?theme=' + currentTheme() + '&r=' + (brandInfo.logo_rev || 0); const src = '/assets/logo.svg?r=' + (brandInfo.logo_rev || 0);
if (brandInfo.has_custom_logo) { if (brandInfo.has_custom_logo) {
return el('div', {class:'brand-row has-custom-logo'}, [ return el('div', {class:'brand-row has-custom-logo'}, [
el('img', {src, alt:'logo'}), el('img', {src, alt:'logo'}),
@@ -2137,29 +1908,18 @@
window.history.replaceState({}, '', window.location.pathname); window.history.replaceState({}, '', window.location.pathname);
} }
// v0.5.2: FleetDM-style separation. The local credential form is its const ssoButton = ssoConfig && ssoConfig.enabled && (ssoConfig.metadata_url || ssoConfig.metadata)
// own self-contained <form>; when SSO is enabled, a distinct ? el('button', {type:'button', class:'sso-btn', onclick: () => {
// "Sign in with …" button sits below a divider — the credential // SP-initiated SAML login (v0.5.1): hand off to the IdP. The
// fields no longer double as the SSO trigger. // /api/sso/acs endpoint verifies the response, mints the
const ssoLive = ssoConfig && ssoConfig.enabled && (ssoConfig.metadata_url || ssoConfig.metadata); // operator session, and redirects back to the dashboard.
const ssoBlock = ssoLive window.location.assign('/api/sso/login');
? el('div', {class:'sso-block'}, [ }}, [
el('div', {class:'auth-divider'}, el('span', {}, 'or')), el('div', {}, 'Sign in with ' + (ssoConfig.idp_name || 'SSO')),
el('button', {type:'button', class:'sso-btn', onclick: () => {
// SP-initiated SAML login: hand off to the IdP. /api/sso/acs
// verifies the response, mints the operator session, and
// redirects back to the dashboard.
window.location.assign('/api/sso/login');
}}, [
ssoConfig.idp_logo_url
? el('img', {class:'sso-logo', src: ssoConfig.idp_logo_url, alt:'', onerror: function(){ this.style.display='none'; }})
: null,
el('span', {}, 'Sign in with ' + (ssoConfig.idp_name || 'SSO')),
]),
]) ])
: null; : null;
const form = el('form', {class:'auth-form local-login', onsubmit: async (e) => { const form = el('form', {class:'auth-form', onsubmit: async (e) => {
e.preventDefault(); e.preventDefault();
err.style.display = 'none'; err.style.display = 'none';
submit.disabled = true; submit.disabled = true;
@@ -2187,6 +1947,9 @@
submit.textContent = 'Sign in'; submit.textContent = 'Sign in';
} }
}}, [ }}, [
authBrandRow(),
el('h2', {}, 'Sign in'),
el('p', {class:'lede'}, 'Enter your administrator credentials. Forgot them? SSH to the host and remove work_dir/auth.json — the next launch will re-prompt for setup.'),
el('label', {class:'field'}, [ el('label', {class:'field'}, [
el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Username'), el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Username'),
usernameInput, usernameInput,
@@ -2196,16 +1959,11 @@
passwordInput, passwordInput,
]), ]),
submit, submit,
]); ssoButton,
return el('div', {class:'login-stack'}, [
authBrandRow(),
el('h2', {}, 'Sign in'),
el('p', {class:'lede'}, 'Enter your administrator credentials. Forgot them? SSH to the host and remove work_dir/auth.json — the next launch will re-prompt for setup.'),
form,
ssoBlock,
err, err,
el('div', {class:'auth-foot'}, 'OpenPXE · ' + (window.location.host || '')), el('div', {class:'auth-foot'}, 'OpenPXE · ' + (window.location.host || '')),
]); ]);
return form;
} }
function buildSetupCard() { function buildSetupCard() {
+1 -4
View File
@@ -13,10 +13,7 @@
on the asset handlers, the practical caching window is one on the asset handlers, the practical caching window is one
version. --> version. -->
<link rel="stylesheet" href="/assets/app.css?v={{ASSET_VERSION}}" /> <link rel="stylesheet" href="/assets/app.css?v={{ASSET_VERSION}}" />
<!-- v0.5.2: favicon is pinned to the bundled OpenPXE mark (its own <link rel="icon" type="image/svg+xml" href="/assets/logo.svg?v={{ASSET_VERSION}}&r={{LOGO_REV}}" />
endpoint, decoupled from operator branding) for tab-icon
continuity regardless of any uploaded light/dark/client logo. -->
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg?v={{ASSET_VERSION}}" />
<!-- Theme is read from localStorage *before* paint to avoid the <!-- Theme is read from localStorage *before* paint to avoid the
dark→light flash on every navigation. Falls back to the OS dark→light flash on every navigation. Falls back to the OS
preference and finally to dark. --> preference and finally to dark. -->
+28 -32
View File
@@ -60,41 +60,35 @@ COPY deploy/ipxe/local/ deploy/ipxe/local/
RUN mkdir -p assets/ipxe && bash scripts/build-ipxe.sh /src/assets/ipxe RUN mkdir -p assets/ipxe && bash scripts/build-ipxe.sh /src/assets/ipxe
########## build openpxe ########## ########## build openpxe ##########
# v0.5.2: cross-compile the Rust binary NATIVELY — no QEMU. FROM rust:${RUST_VERSION}-bookworm AS build
#
# This stage is pinned to $BUILDPLATFORM (the builder's native arch — arm64
# on an Apple-Silicon Mac, amd64 in x86 CI), exactly like `ipxe-build`. The
# Rust compiler therefore runs at full native speed and emits an
# x86_64-unknown-linux-musl binary via `cargo-zigbuild`, which uses `zig cc`
# as the cross-linker (it bundles the musl sysroot for every target, so
# there's no fiddly cross-gcc toolchain to assemble).
#
# Why this replaced the old `FROM rust ... --platform=linux/amd64` build:
# that ran the *entire* compiler under QEMU x86_64 emulation on the arm64
# host. It was ~15x slower (a single crate took >20 min) and the emulated
# gcc/linker intermittently SIGSEGV'd or hung mid-link. Cross-compiling
# sidesteps emulation entirely — the build is minutes, not half an hour,
# and is deterministic.
#
# The output is still a fully static musl binary with no glibc dependency,
# so the runtime stage stays free to be any Linux distro.
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-bookworm AS build
WORKDIR /src WORKDIR /src
# zig (via the `ziglang` pip package — cargo-zigbuild auto-discovers it as
# `python3 -m ziglang`) supplies the x86_64 musl sysroot + linker.
# cargo-zigbuild is the thin cargo wrapper that wires zig in as the linker.
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 python3-pip \
&& rm -rf /var/lib/apt/lists/* \
&& rustup target add x86_64-unknown-linux-musl \
&& pip3 install --no-cache-dir --break-system-packages ziglang \
&& cargo install --locked cargo-zigbuild
# 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
# with stubs, then real build" dance for dep-compile reuse; that turned out
# to silently serve stale stub binaries when cargo's fingerprint didn't
# notice the source swap. A single build is ~1.5 min longer on cold cache
# but guarantees the binary reflects the sources we copied.
# Do not copy rust-toolchain.toml into the image. The local workspace pins # Do not copy rust-toolchain.toml into the image. The local workspace pins
# developer tooling, but inside Docker we intentionally use the Rust version # developer tooling, but inside Docker we intentionally use the Rust version
# selected by the base image. Copying rust-toolchain.toml with # selected by the base image. Copying rust-toolchain.toml with
# `channel = "stable"` makes rustup download a second full toolchain during # `channel = "stable"` makes rustup download a second full toolchain during
# the build, which is slow and can exhaust small Colima/CI disks. # `cargo build`, which is slow and can exhaust small Colima/CI disks.
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/ COPY crates/ crates/
# Baseline binaries (BIOS / i386 / wimboot), then overlay the # Baseline binaries (BIOS / i386 / wimboot), then overlay the
@@ -106,11 +100,13 @@ COPY --from=fetch /src/assets/ipxe /src/assets/ipxe
COPY --from=ipxe-build /src/assets/ipxe/snponly.efi /src/assets/ipxe/snponly.efi COPY --from=ipxe-build /src/assets/ipxe/snponly.efi /src/assets/ipxe/snponly.efi
COPY --from=ipxe-build /src/assets/ipxe/ipxe.efi /src/assets/ipxe/ipxe.efi COPY --from=ipxe-build /src/assets/ipxe/ipxe.efi /src/assets/ipxe/ipxe.efi
# Cache cargo registry + target across builds. `cargo zigbuild` runs the # Cache cargo registry + target across builds. The mtime touch is
# native rustc (fast) and links for x86_64-musl with zig — no emulation. # belt-and-suspenders: cargo occasionally misses mtime-only changes on
# 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 \
cargo zigbuild --release --target x86_64-unknown-linux-musl --bin openpxe && \ find crates -name '*.rs' -exec touch {} + && \
cargo build --release --target x86_64-unknown-linux-musl --bin openpxe && \
cp target/x86_64-unknown-linux-musl/release/openpxe /openpxe && \ cp target/x86_64-unknown-linux-musl/release/openpxe /openpxe && \
ls -l /openpxe ls -l /openpxe