Compare commits

...
11 Commits
Author SHA1 Message Date
Miles WardandClaude Opus 4.7 0afbe860e8 v0.4.64: NFS mount diagnostics — pre-flight probe, retry, hint translation
The dominant field failure from v0.4.63 was "mount.nfs: failed to apply
fstab options" (exit 32), surfaced verbatim by the Storage tab. The
message is misleading — it has nothing to do with /etc/fstab; it comes
from nfs-utils 2.6.x's nfs_options2string() and most commonly indicates
the container is missing CAP_SYS_ADMIN, /etc/mtab is unwritable, or an
auxiliary option triggered an option-transform edge case.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-24 13:10:40 -04:00
36 changed files with 8884 additions and 316 deletions
-1
View File
@@ -1,5 +1,4 @@
/target /target
Cargo.lock
data/isos/*.iso data/isos/*.iso
data/isos/*.partial data/isos/*.partial
data/isos/*.meta.json data/isos/*.meta.json
Generated
+2619
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -12,9 +12,9 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.3.2" version = "0.4.64"
edition = "2021" edition = "2021"
rust-version = "1.80" rust-version = "1.95"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
repository = "https://gitea.milesward.dev/mward4/OpenPXE" repository = "https://gitea.milesward.dev/mward4/OpenPXE"
authors = ["OpenPXE contributors"] authors = ["OpenPXE contributors"]
+10 -11
View File
@@ -5,11 +5,11 @@ Container-native PXE boot server. A Rust reimplementation of
for Docker/OCI and OpenShift. Upload `.iso` files via the web UI; network for Docker/OCI and OpenShift. Upload `.iso` files via the web UI; network
clients PXE-boot them. clients PXE-boot them.
> **Status:** v0.3.2 / pre-beta. Phases 15 complete: full PXE stack, > **Status:** v0.4.1 / pre-beta. Phases 15 complete: full PXE stack,
> Queued Deployment queue, NFS-share ISO sources, live tracing log + an > Queued Deployment queue, NFS-share ISO sources, live tracing log + an
> operator terminal, per-MAC host bindings (Tinkerbell-style), > operator terminal, per-MAC host bindings, Prometheus `/metrics`,
> Prometheus `/metrics`, light/dark theme toggle, animated OpenPXE > light/dark theme toggle, animated OpenPXE imaging-progress widget,
> imaging-progress widget, and per-ISO boot passwords. The test suite and > chunked ISO uploads, and per-ISO boot passwords. The test suite and
> clippy are part of the release checklist. Ready for real-hardware validation. > clippy are part of the release checklist. Ready for real-hardware validation.
## Design non-negotiables ## Design non-negotiables
@@ -51,8 +51,7 @@ clients PXE-boot them.
widget when devices are imaging. All assets served from the binary — widget when devices are imaging. All assets served from the binary —
no external requests. no external requests.
8. **Per-MAC host bindings.** Pin a MAC to a boot target and the client 8. **Per-MAC host bindings.** Pin a MAC to a boot target and the client
skips the menu, chains straight through. Inspired by Tinkerbell's skips the menu, chains straight through.
`smee` MAC-prepended URL pattern.
9. **Prometheus metrics** at `/metrics` — DHCP replies by arch, TFTP 9. **Prometheus metrics** at `/metrics` — DHCP replies by arch, TFTP
transfer counts and bytes, HTTP request counts by route, queue / transfer counts and bytes, HTTP request counts by route, queue /
imaging gauges, uptime, build info. Plain text exposition format, imaging gauges, uptime, build info. Plain text exposition format,
@@ -81,7 +80,7 @@ skip TFTP and respond with an HTTP URL.
./scripts/fetch-ipxe.sh ./scripts/fetch-ipxe.sh
# 2. Build the container image (~3 min first time). # 2. Build the container image (~3 min first time).
docker buildx build -f deploy/docker/Dockerfile -t openpxe:0.3.2 --load . docker buildx build -f deploy/docker/Dockerfile -t openpxe:0.4.1 --load .
# 3. Run it on the box plugged into your PXE network. Set PUBLIC_IP to # 3. Run it on the box plugged into your PXE network. Set PUBLIC_IP to
# this host's LAN address so advertised iPXE URLs are reachable. # this host's LAN address so advertised iPXE URLs are reachable.
@@ -91,7 +90,7 @@ docker run -d --name openpxe \
-e OPENPXE_DHCP_MODE=proxy \ -e OPENPXE_DHCP_MODE=proxy \
-v $PWD/data/isos:/var/lib/openpxe/isos \ -v $PWD/data/isos:/var/lib/openpxe/isos \
-v $PWD/data/work:/var/lib/openpxe/work \ -v $PWD/data/work:/var/lib/openpxe/work \
openpxe:0.3.2 openpxe:0.4.1
# 4. Open the UI and drop an ISO in. # 4. Open the UI and drop an ISO in.
open http://10.0.0.5 open http://10.0.0.5
@@ -122,7 +121,7 @@ docker buildx create --name openpxe-multi --driver docker-container --use
# Build + push both linux/amd64 and linux/arm64 under one tag. # Build + push both linux/amd64 and linux/arm64 under one tag.
docker buildx build --builder openpxe-multi \ docker buildx build --builder openpxe-multi \
--platform linux/amd64,linux/arm64 \ --platform linux/amd64,linux/arm64 \
-t ghcr.io/YOUR-ORG/openpxe:0.3.2 \ -t ghcr.io/YOUR-ORG/openpxe:0.4.1 \
--push \ --push \
-f deploy/docker/Dockerfile . -f deploy/docker/Dockerfile .
``` ```
@@ -155,10 +154,10 @@ docker run --rm \
-v /my/iso-library:/seed:ro \ -v /my/iso-library:/seed:ro \
-v openpxe-data:/var/lib/openpxe/isos \ -v openpxe-data:/var/lib/openpxe/isos \
-e OPENPXE_PUBLIC_IP=10.0.0.5 \ -e OPENPXE_PUBLIC_IP=10.0.0.5 \
openpxe:0.3.2 seed --from /seed openpxe:0.4.1 seed --from /seed
# Dry run first to see what would be imported: # Dry run first to see what would be imported:
docker run --rm -v /my/iso-library:/seed:ro openpxe:0.3.2 seed --from /seed --dry-run docker run --rm -v /my/iso-library:/seed:ro openpxe:0.4.1 seed --from /seed --dry-run
``` ```
### Environment overrides ### Environment overrides
+3
View File
@@ -21,6 +21,9 @@ time.workspace = true
uuid.workspace = true uuid.workspace = true
parking_lot.workspace = true parking_lot.workspace = true
tokio = { workspace = true, features = ["sync", "rt", "macros", "time"] } tokio = { workspace = true, features = ["sync", "rt", "macros", "time"] }
# bcrypt for the admin Forms auth (v0.4.5). Already in the workspace
# for per-ISO boot passwords; just re-exported here.
bcrypt.workspace = true
[dev-dependencies] [dev-dependencies]
tempfile = "3.12" tempfile = "3.12"
+353
View File
@@ -0,0 +1,353 @@
//! Operator authentication — Sonarr/Radarr-style single-admin Forms model.
//!
//! On a fresh install, no admin account exists; the WebUI's first-run
//! flow prompts the operator to create one. After that the chosen
//! credentials gate `/api/*` access. The admin can rotate username +
//! password from Settings → Account.
//!
//! Multi-user RBAC isn't a goal for OpenPXE — the user explicitly asked
//! for "you have access or you don't". When SSO is configured, additional
//! users come in through the IdP; the locally-stored admin is the
//! fallback owner who can change SSO config or the seal-breaker for an
//! IdP outage. So one record is enough.
//!
//! Storage policy mirrors [`crate::host_bindings::HostBindings`] and
//! [`crate::boot_log::BootLog`]: in-memory authoritative; disk is the
//! crash-survival cache; a corrupt `auth.json` falls back to "no admin
//! configured" rather than blocking startup, which puts the UI back
//! into setup mode rather than locking the operator out.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use time::OffsetDateTime;
use crate::{Error, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdminAccount {
pub username: String,
/// bcrypt hash (cost 10). The plaintext password never leaves the
/// request that set it — same discipline as the per-ISO boot password.
pub password_hash: String,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
}
/// Public projection — no hash, safe to ship to the WebUI.
#[derive(Debug, Clone, Serialize)]
pub struct AdminPublic {
pub username: String,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
}
impl From<&AdminAccount> for AdminPublic {
fn from(a: &AdminAccount) -> Self {
Self {
username: a.username.clone(),
created_at: a.created_at,
updated_at: a.updated_at,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Inner {
admin: Option<AdminAccount>,
}
/// In-memory + on-disk admin registry. Cheap to clone.
#[derive(Debug, Clone)]
pub struct AdminStore {
path: Arc<PathBuf>,
inner: Arc<RwLock<Inner>>,
}
impl AdminStore {
/// Load from `<work_dir>/auth.json`, or start empty. A bad file
/// logs a warning and falls back to "no admin configured" — better
/// to surface the setup flow than lock the operator out of their
/// own install.
#[must_use]
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("auth.json");
let inner = match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str::<Inner>(&text) {
Ok(parsed) => parsed,
Err(e) => {
tracing::warn!(
target: "openpxe::auth",
"auth.json present but unreadable ({e}); starting in setup mode"
);
Inner::default()
}
},
Err(_) => Inner::default(),
};
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(inner)),
}
}
/// Has an admin been bootstrapped? Drives the first-run / login
/// fork in the HTTP layer.
#[must_use]
pub fn is_configured(&self) -> bool {
self.inner.read().admin.is_some()
}
/// Public-safe snapshot for the WebUI.
#[must_use]
pub fn snapshot(&self) -> Option<AdminPublic> {
self.inner.read().admin.as_ref().map(AdminPublic::from)
}
/// First-run setup: create the admin account. Fails if one already
/// exists — the HTTP layer surfaces that as 409.
pub fn bootstrap(&self, username: &str, password: &str) -> Result<AdminPublic> {
validate_username(username)?;
validate_password(password)?;
let hash = bcrypt_hash(password)?;
let now = OffsetDateTime::now_utc();
let admin = AdminAccount {
username: username.trim().to_string(),
password_hash: hash,
created_at: now,
updated_at: now,
};
{
let mut g = self.inner.write();
if g.admin.is_some() {
return Err(Error::Invalid(
"admin account already configured".into(),
));
}
g.admin = Some(admin.clone());
}
self.persist();
tracing::info!(
target: "openpxe::auth",
username = %admin.username,
"admin account created (first-run setup)"
);
Ok((&admin).into())
}
/// Verify credentials. Returns the admin record (public projection)
/// on success, `Ok(None)` on mismatch, `Err` on systemic bcrypt
/// failure (treated as "auth not available right now" by callers).
pub fn verify(&self, username: &str, password: &str) -> Result<Option<AdminPublic>> {
let Some(admin) = self.inner.read().admin.clone() else {
return Ok(None);
};
if username.trim() != admin.username {
return Ok(None);
}
// bcrypt compares in constant time relative to the same hash.
// Doing the username check first is fine — a username mismatch
// returns immediately, but the only thing leaked is "this isn't
// the admin's username" which the operator already knows.
match bcrypt::verify(password, &admin.password_hash) {
Ok(true) => Ok(Some((&admin).into())),
Ok(false) => Ok(None),
Err(e) => Err(Error::Other(e.into())),
}
}
/// Rotate username and/or password. `current_password` must match
/// the *existing* hash — same flow as Sonarr's "current password
/// required to change". `new_username`/`new_password` are optional:
/// pass only what you want to change.
pub fn update_credentials(
&self,
current_password: &str,
new_username: Option<&str>,
new_password: Option<&str>,
) -> Result<AdminPublic> {
// Re-check ownership before any state mutation.
let existing = self
.inner
.read()
.admin
.clone()
.ok_or_else(|| Error::Invalid("no admin configured".into()))?;
match bcrypt::verify(current_password, &existing.password_hash) {
Ok(true) => {}
Ok(false) => return Err(Error::Invalid("current password is incorrect".into())),
Err(e) => return Err(Error::Other(e.into())),
}
let mut updated = existing.clone();
if let Some(u) = new_username {
validate_username(u)?;
updated.username = u.trim().to_string();
}
if let Some(p) = new_password {
validate_password(p)?;
updated.password_hash = bcrypt_hash(p)?;
}
updated.updated_at = OffsetDateTime::now_utc();
{
let mut g = self.inner.write();
g.admin = Some(updated.clone());
}
self.persist();
tracing::info!(
target: "openpxe::auth",
username = %updated.username,
"admin credentials updated"
);
Ok((&updated).into())
}
fn persist(&self) {
let snap = self.inner.read().clone();
let body = match serde_json::to_vec_pretty(&snap) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::auth", "serialize auth.json: {e}");
return;
}
};
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = self.path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::auth", "write auth.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::warn!(target: "openpxe::auth", "rename auth.json: {e}");
}
}
}
fn validate_username(u: &str) -> Result<()> {
let u = u.trim();
if u.is_empty() {
return Err(Error::Invalid("username must not be empty".into()));
}
if u.len() > 64 {
return Err(Error::Invalid("username must be 64 chars or fewer".into()));
}
if !u.chars().all(|c| c.is_ascii_graphic() && c != ':') {
return Err(Error::Invalid(
"username must be ASCII printable with no ':' character".into(),
));
}
Ok(())
}
fn validate_password(p: &str) -> Result<()> {
if p.len() < 8 {
return Err(Error::Invalid(
"password must be at least 8 characters".into(),
));
}
if p.len() > 256 {
return Err(Error::Invalid(
"password must be 256 characters or fewer".into(),
));
}
Ok(())
}
fn bcrypt_hash(password: &str) -> Result<String> {
bcrypt::hash(password, bcrypt::DEFAULT_COST).map_err(|e| Error::Other(e.into()))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn empty_after_load_when_no_file() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
assert!(!s.is_configured());
assert!(s.snapshot().is_none());
}
#[test]
fn bootstrap_then_verify_round_trip() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
let pub_ = s.bootstrap("admin", "hunter2hunter2").unwrap();
assert_eq!(pub_.username, "admin");
assert!(s.is_configured());
// Correct creds match; wrong creds don't.
assert!(s.verify("admin", "hunter2hunter2").unwrap().is_some());
assert!(s.verify("admin", "wrong").unwrap().is_none());
assert!(s.verify("nobody", "hunter2hunter2").unwrap().is_none());
}
#[test]
fn bootstrap_rejects_second_call() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
s.bootstrap("admin", "hunter2hunter2").unwrap();
let r = s.bootstrap("other", "anotherpass1");
assert!(matches!(r, Err(Error::Invalid(_))));
}
#[test]
fn round_trip_survives_disk_reload() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
s.bootstrap("admin", "hunter2hunter2").unwrap();
drop(s);
let s2 = AdminStore::load_or_default(dir.path());
assert!(s2.is_configured());
assert!(s2.verify("admin", "hunter2hunter2").unwrap().is_some());
}
#[test]
fn update_credentials_requires_current_password() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
s.bootstrap("admin", "hunter2hunter2").unwrap();
// Wrong current password → no change.
let r = s.update_credentials("nope", None, Some("newpassword1"));
assert!(matches!(r, Err(Error::Invalid(_))));
assert!(s.verify("admin", "hunter2hunter2").unwrap().is_some());
// Correct current password rotates only what's supplied.
s.update_credentials("hunter2hunter2", Some("alice"), Some("newpassword1"))
.unwrap();
assert!(s.verify("admin", "hunter2hunter2").unwrap().is_none());
assert!(s.verify("alice", "newpassword1").unwrap().is_some());
}
#[test]
fn update_credentials_partial_password_only_keeps_username() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
s.bootstrap("admin", "hunter2hunter2").unwrap();
s.update_credentials("hunter2hunter2", None, Some("newpassword1"))
.unwrap();
assert!(s.verify("admin", "newpassword1").unwrap().is_some());
}
#[test]
fn validates_username_and_password() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
assert!(s.bootstrap("", "hunter2hunter2").is_err());
assert!(s.bootstrap("ad:min", "hunter2hunter2").is_err()); // ':' reserved
assert!(s.bootstrap("admin", "short").is_err()); // <8 chars
// 65-char username is too long.
let long = "a".repeat(65);
assert!(s.bootstrap(&long, "hunter2hunter2").is_err());
}
}
+249
View File
@@ -0,0 +1,249 @@
//! Boot-event log — "who installed what, when, from where".
//!
//! Each `/boot/<entry>.ipxe` fetch that actually goes on to serve a boot
//! script lands an entry here. The log is bounded in memory (newest-first,
//! ring-buffered at [`BootLog::CAP`]) and is mirrored append-only to
//! `<work_dir>/boot_log.jsonl`. Mirrors `HostBindings`'s "in-memory is
//! authoritative, disk is a cache" policy — a corrupt log file should
//! never block PXE for the network.
//!
//! We deliberately don't push these onto the `LogBus` (the operator
//! terminal stream). The terminal already shows the http traces; the
//! Host log is a curated, persistent, easy-to-scan view of "what got
//! imaged on what hardware" and conflating the two would be noisy.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::io::Write;
use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::Arc;
use time::OffsetDateTime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootEvent {
#[serde(with = "time::serde::rfc3339")]
pub timestamp: OffsetDateTime,
/// Lowercase, colon-separated. `None` when iPXE didn't supply
/// `?mac=${mac}` in the chain URL (older bookmarks, custom scripts).
pub mac: Option<String>,
/// Connecting peer's IP — taken from the TCP socket when available
/// (PXE clients connect direct, no reverse proxy), and falls back to
/// `X-Forwarded-For` for the rare case where one is present.
pub ip: Option<IpAddr>,
/// `BootEntry::id` — the same id used in `/boot/<id>.ipxe`.
pub target_id: String,
/// Human-friendly label: the ISO's filename / volume label / entry
/// title. Pre-resolved at log time so the UI can render without
/// joining against the ISO store (and so "what image was installed?"
/// survives the operator deleting the ISO later).
pub target_title: String,
}
/// In-memory ring + disk-backed append log of boot events. Cheap to
/// clone; the inner state is `Arc<RwLock<_>>`.
#[derive(Debug, Clone)]
pub struct BootLog {
path: Arc<PathBuf>,
inner: Arc<RwLock<VecDeque<BootEvent>>>,
}
impl BootLog {
/// Newest entries we retain in memory. Past this, the oldest gets
/// evicted — the on-disk JSONL keeps the full history for offline
/// inspection. 500 covers a typical install-day's worth without
/// turning the Hosts tab into a wall of text.
pub const CAP: usize = 500;
/// Load up to `CAP` newest events from `<work_dir>/boot_log.jsonl`,
/// or start empty if the file is missing / unreadable.
#[must_use]
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("boot_log.jsonl");
let mut events = VecDeque::with_capacity(Self::CAP);
if let Ok(text) = std::fs::read_to_string(&path) {
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<BootEvent>(line) {
Ok(ev) => {
if events.len() == Self::CAP {
events.pop_front();
}
events.push_back(ev);
}
Err(e) => {
tracing::warn!(
target: "openpxe::boot_log",
"skipping unparseable boot_log line: {e}"
);
}
}
}
}
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(events)),
}
}
/// Append an event. Persistence is best-effort and never blocks the
/// caller on a failed write (the in-memory copy is the source of
/// truth for the live UI; the JSONL is just for crash survival).
pub fn record(&self, ev: &BootEvent) {
// Push into the ring first so a slow / failing disk doesn't lose
// events for the live UI.
{
let mut g = self.inner.write();
if g.len() == Self::CAP {
g.pop_front();
}
g.push_back(ev.clone());
}
tracing::info!(
target: "openpxe::boot_log",
mac = ev.mac.as_deref().unwrap_or("?"),
ip = ev.ip.map(|i| i.to_string()).as_deref().unwrap_or("?"),
target = %ev.target_id,
"boot event"
);
// Append to disk. We tolerate write failures — they'd show up as
// missing entries on the next restart only.
let mut line = match serde_json::to_string(ev) {
Ok(s) => s,
Err(e) => {
tracing::warn!(target: "openpxe::boot_log", "serialize boot event: {e}");
return;
}
};
line.push('\n');
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(self.path.as_path())
{
Ok(mut f) => {
if let Err(e) = f.write_all(line.as_bytes()) {
tracing::warn!(target: "openpxe::boot_log", "append boot_log.jsonl: {e}");
}
}
Err(e) => {
tracing::warn!(target: "openpxe::boot_log", "open boot_log.jsonl: {e}");
}
}
}
/// Newest-first snapshot, up to `CAP` entries.
#[must_use]
pub fn list(&self) -> Vec<BootEvent> {
let g = self.inner.read();
// VecDeque preserves insertion order; reverse so newest is first.
g.iter().rev().cloned().collect()
}
#[must_use]
pub fn len(&self) -> usize {
self.inner.read().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Wipe in-memory + the on-disk file. Used by the `terminal clear`
/// equivalent or future operator action; not currently wired to a UI
/// button but exposed for completeness.
pub fn clear(&self) {
self.inner.write().clear();
let _ = std::fs::remove_file(self.path.as_path());
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn ev(target: &str, mac: Option<&str>) -> BootEvent {
BootEvent {
timestamp: OffsetDateTime::now_utc(),
mac: mac.map(str::to_string),
ip: Some("10.0.0.42".parse().unwrap()),
target_id: target.into(),
target_title: format!("{target}.iso"),
}
}
#[test]
fn record_then_list_is_newest_first() {
let dir = tempdir().unwrap();
let log = BootLog::load_or_default(dir.path());
assert!(log.is_empty());
log.record(&ev("alpha", Some("aa:bb:cc:00:00:01")));
log.record(&ev("beta", Some("aa:bb:cc:00:00:02")));
let list = log.list();
assert_eq!(list.len(), 2);
assert_eq!(list[0].target_id, "beta");
assert_eq!(list[1].target_id, "alpha");
}
#[test]
fn round_trip_through_disk() {
let dir = tempdir().unwrap();
let log = BootLog::load_or_default(dir.path());
log.record(&ev("alpha", Some("aa:bb:cc:00:00:01")));
log.record(&ev("beta", None));
drop(log);
let log2 = BootLog::load_or_default(dir.path());
assert_eq!(log2.len(), 2);
let list = log2.list();
assert_eq!(list[0].target_id, "beta");
assert_eq!(list[1].target_id, "alpha");
assert!(list[0].mac.is_none());
assert_eq!(list[1].mac.as_deref(), Some("aa:bb:cc:00:00:01"));
}
#[test]
fn ring_evicts_oldest_past_cap() {
let dir = tempdir().unwrap();
let log = BootLog::load_or_default(dir.path());
for i in 0..(BootLog::CAP + 5) {
log.record(&ev(&format!("e{i}"), None));
}
assert_eq!(log.len(), BootLog::CAP);
let list = log.list();
// Newest first; the most recent push is the last index inserted.
assert_eq!(list[0].target_id, format!("e{}", BootLog::CAP + 4));
// Oldest in-memory should be the 6th push (0..5 were evicted).
assert_eq!(list[BootLog::CAP - 1].target_id, "e5");
}
#[test]
fn clear_wipes_memory_and_disk() {
let dir = tempdir().unwrap();
let log = BootLog::load_or_default(dir.path());
log.record(&ev("alpha", None));
log.clear();
assert!(log.is_empty());
let log2 = BootLog::load_or_default(dir.path());
assert!(log2.is_empty());
}
#[test]
fn corrupt_disk_lines_are_skipped_not_fatal() {
// Write a file with one valid + one garbage line; loader should
// surface the valid one and skip the garbage.
let dir = tempdir().unwrap();
let path = dir.path().join("boot_log.jsonl");
let valid = serde_json::to_string(&ev("ok", Some("aa:bb:cc:00:00:09"))).unwrap();
std::fs::write(&path, format!("{valid}\nNOT_JSON\n{valid}\n")).unwrap();
let log = BootLog::load_or_default(dir.path());
assert_eq!(log.len(), 2);
}
}
+339
View File
@@ -0,0 +1,339 @@
//! Operator-controlled branding overrides.
//!
//! The browser tab's logo (`/assets/logo.svg`) defaults to the bundled
//! rainbow-horizon mark. Operators who deploy OpenPXE behind their own
//! branding can upload a replacement that lives at
//! `<work_dir>/branding/logo.<ext>` and is served in preference to the
//! bundled SVG when present. Borrowed-from-FleetDM: tenant chrome, same
//! product.
//!
//! Storage policy mirrors `HostBindings` / `BootLog`: in-memory cache is
//! authoritative for the current process, disk is the source of truth on
//! restart, and a corrupt cache file falls back to the bundled default
//! rather than blocking startup.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Allowed MIME types for an uploaded logo. We deliberately keep this
/// narrow — anything that can be `<img src="...">`'d into the brand
/// block, no scripts. SVG carries the obvious XSS risk for raw inline
/// HTML; we always serve the bytes as a separate asset with a strict
/// content-type rather than inlining, so SVG is safe.
pub const ALLOWED_LOGO_MIMES: &[&str] = &[
"image/svg+xml",
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
];
/// Disk cap for an uploaded logo. PXE WebUIs are operator-facing — even
/// a generous 2 MB cap is comfortable for any reasonable brand mark and
/// puts a clear bound on memory + serialization cost.
pub const MAX_LOGO_BYTES: usize = 2 * 1024 * 1024;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Inner {
/// File name (relative to the branding dir) for the active logo, if
/// any. Always under `<work_dir>/branding/`; never an absolute path
/// from the operator.
logo_filename: Option<String>,
/// MIME of the active logo, mirroring `logo_filename`. Cached here
/// so the HTTP layer can set Content-Type without re-sniffing.
logo_mime: Option<String>,
}
/// In-memory + on-disk override registry. Cheap to clone; locks are
/// brief. The `branding.json` cache lives alongside the active asset
/// inside `<work_dir>/branding/`.
#[derive(Debug, Clone)]
pub struct BrandingStore {
/// Root directory: `<work_dir>/branding/`. Created on first write.
dir: Arc<PathBuf>,
inner: Arc<RwLock<Inner>>,
}
impl BrandingStore {
/// Load (or initialise empty) from `<work_dir>/branding/`. Tolerates
/// missing directories, partial state, and corrupt JSON — a bad
/// cache should never block PXE for the network.
#[must_use]
pub fn load_or_default(work_dir: &Path) -> Self {
let dir = work_dir.join("branding");
let path = dir.join("branding.json");
let mut inner = Inner::default();
if let Ok(text) = std::fs::read_to_string(&path) {
match serde_json::from_str::<Inner>(&text) {
Ok(parsed) => {
// 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) => {
tracing::warn!(
target: "openpxe::branding",
"branding.json present but unreadable ({e}); starting empty"
);
}
}
}
Self {
dir: Arc::new(dir),
inner: Arc::new(RwLock::new(inner)),
}
}
/// Absolute path to the active logo, if one is set and present on
/// disk. `None` means the HTTP layer should serve the bundled SVG.
#[must_use]
pub fn logo_path(&self) -> Option<PathBuf> {
let g = self.inner.read();
g.logo_filename.as_deref().map(|n| self.dir.join(n))
}
/// MIME of the active logo, if any. The HTTP layer pairs this with
/// the bytes returned by [`Self::logo_path`].
#[must_use]
pub fn logo_mime(&self) -> Option<String> {
self.inner.read().logo_mime.clone()
}
/// Replace the active logo. Returns the chosen on-disk filename so
/// the caller can echo it back in the API response. Old logos are
/// removed best-effort.
pub fn set_logo(&self, mime: &str, ext: &str, bytes: &[u8]) -> std::io::Result<String> {
std::fs::create_dir_all(self.dir.as_path())?;
// Single canonical filename per upload — overwriting the old one
// (after clearing it) keeps the directory tidy and avoids any
// path-traversal concern: the operator never supplies the name.
let safe_ext = sanitize_ext(ext);
let filename = format!("logo.{safe_ext}");
let final_path = self.dir.join(&filename);
// Atomic write: tmp -> rename. Guarantees the file is either
// entirely the old logo or entirely the new one.
let tmp = final_path.with_extension(format!("{safe_ext}.tmp"));
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, &final_path)?;
// Clean up any sibling logo.<otherext> so there's exactly one
// canonical file at any time.
if let Ok(entries) = std::fs::read_dir(self.dir.as_path()) {
for e in entries.flatten() {
let p = e.path();
let name = p
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
if name.starts_with("logo.") && name != filename {
let _ = std::fs::remove_file(&p);
}
}
}
{
let mut g = self.inner.write();
g.logo_filename = Some(filename.clone());
g.logo_mime = Some(mime.to_string());
}
self.persist();
tracing::info!(
target: "openpxe::branding",
file = %filename, mime = %mime, size = bytes.len(),
"custom logo installed"
);
Ok(filename)
}
/// Drop the override and return to the bundled SVG.
pub fn clear_logo(&self) -> std::io::Result<()> {
let removed = {
let mut g = self.inner.write();
let removed = g.logo_filename.take();
g.logo_mime = None;
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();
Ok(())
}
/// 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]
pub fn has_logo(&self) -> bool {
self.inner.read().logo_filename.is_some()
}
fn persist(&self) {
let snap = self.inner.read().clone();
let body = match serde_json::to_vec_pretty(&snap) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::branding", "serialize branding.json: {e}");
return;
}
};
if let Err(e) = std::fs::create_dir_all(self.dir.as_path()) {
tracing::warn!(target: "openpxe::branding", "mkdir branding/: {e}");
return;
}
let path = self.dir.join("branding.json");
let tmp = path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::branding", "write branding.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, &path) {
tracing::warn!(target: "openpxe::branding", "rename branding.json: {e}");
}
}
}
/// Trim arbitrary operator-supplied extension strings to a small, safe
/// alphanumeric form. Anything weird collapses to `bin`. We never let
/// the extension affect the path beyond the final segment of `logo.<x>`.
fn sanitize_ext(ext: &str) -> String {
let lc: String = ext
.chars()
.filter(char::is_ascii_alphanumeric)
.map(|c| c.to_ascii_lowercase())
.collect();
if lc.is_empty() || lc.len() > 5 {
"bin".into()
} else {
lc
}
}
/// Pick a safe filesystem extension from a MIME type. Returns `None`
/// if the MIME isn't on the [`ALLOWED_LOGO_MIMES`] allowlist.
#[must_use]
pub fn ext_for_mime(mime: &str) -> Option<&'static str> {
match mime {
"image/svg+xml" => Some("svg"),
"image/png" => Some("png"),
"image/jpeg" => Some("jpg"),
"image/webp" => Some("webp"),
"image/gif" => Some("gif"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn empty_after_load_when_no_branding_dir() {
let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path());
assert!(!b.has_logo());
assert!(b.logo_path().is_none());
assert!(b.logo_mime().is_none());
}
#[test]
fn set_clear_round_trip_persists() {
let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path());
let name = b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake").unwrap();
assert_eq!(name, "logo.png");
assert!(b.has_logo());
assert_eq!(b.logo_mime().as_deref(), Some("image/png"));
let p = b.logo_path().unwrap();
assert!(p.is_file());
// Re-open and confirm the override survives a restart.
drop(b);
let b2 = BrandingStore::load_or_default(dir.path());
assert!(b2.has_logo());
assert_eq!(b2.logo_mime().as_deref(), Some("image/png"));
// Clear; the file goes away and has_logo flips off.
b2.clear_logo().unwrap();
assert!(!b2.has_logo());
assert!(!p.exists());
}
#[test]
fn replacing_logo_removes_old_extension_sibling() {
// PNG then SVG; only the SVG should remain on disk.
let dir = tempdir().unwrap();
let b = BrandingStore::load_or_default(dir.path());
b.set_logo("image/png", "png", b"\x89PNG\r\n\x1a\nfake").unwrap();
b.set_logo("image/svg+xml", "svg", br#"<svg xmlns="http://www.w3.org/2000/svg"/>"#).unwrap();
let entries: Vec<_> = std::fs::read_dir(dir.path().join("branding"))
.unwrap()
.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned()))
.collect();
assert!(entries.iter().any(|n| n == "logo.svg"), "got {entries:?}");
assert!(!entries.iter().any(|n| n == "logo.png"), "stale PNG left over: {entries:?}");
}
#[test]
fn sanitize_ext_strips_separators_and_path_chars() {
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");
// Short alphanumeric strip-through stays itself.
assert_eq!(sanitize_ext("../svg"), "svg");
assert_eq!(sanitize_ext(""), "bin");
assert_eq!(sanitize_ext("PNG"), "png");
// Anything past five chars is suspicious — collapse to `bin`.
assert_eq!(sanitize_ext("svgvvvv"), "bin");
}
#[test]
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 brand_dir = dir.path().join("branding");
std::fs::create_dir_all(&brand_dir).unwrap();
// Hand-write a branding.json claiming logo.png exists.
let inner = Inner {
logo_filename: Some("logo.png".into()),
logo_mime: Some("image/png".into()),
};
std::fs::write(
brand_dir.join("branding.json"),
serde_json::to_vec_pretty(&inner).unwrap(),
)
.unwrap();
let b = BrandingStore::load_or_default(dir.path());
assert!(!b.has_logo(), "should fall back when referenced file is missing");
}
#[test]
fn ext_for_mime_only_accepts_known_types() {
assert_eq!(ext_for_mime("image/png"), Some("png"));
assert_eq!(ext_for_mime("image/svg+xml"), Some("svg"));
assert_eq!(ext_for_mime("application/octet-stream"), None);
assert_eq!(ext_for_mime("text/html"), None);
}
}
+4 -5
View File
@@ -1,10 +1,9 @@
//! Per-MAC host bindings. //! Per-MAC host bindings.
//! //!
//! Inspired by the Tinkerbell `smee` "MAC-prepended URL" pattern: an //! Operators can attach a preferred boot target (a `BootEntry::id`) to a
//! operator can attach a preferred boot target (a `BootEntry::id`) to a //! specific MAC address. When a client with that MAC arrives, the top-level
//! specific MAC address. When a client with that MAC arrives, the //! boot script chains straight to that target instead of showing the
//! top-level boot script chains straight to that target instead of //! interactive menu.
//! showing the interactive menu.
//! //!
//! Use cases: //! Use cases:
//! - "This rack of Dell servers always images with Ubuntu Server 24.04" //! - "This rack of Dell servers always images with Ubuntu Server 24.04"
+8
View File
@@ -3,6 +3,9 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
pub mod arch; pub mod arch;
pub mod auth;
pub mod boot_log;
pub mod branding;
pub mod client; pub mod client;
pub mod config; pub mod config;
pub mod error; pub mod error;
@@ -11,9 +14,14 @@ pub mod log_bus;
pub mod metrics; pub mod metrics;
pub mod queue; pub mod queue;
pub mod settings; pub mod settings;
pub mod sso;
pub use arch::{ClientArch, FirmwareClass}; pub use arch::{ClientArch, FirmwareClass};
pub use auth::{AdminAccount, AdminPublic, AdminStore};
pub use boot_log::{BootEvent, BootLog};
pub use branding::{ext_for_mime, BrandingStore, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES};
pub use client::{ClientEvent, ClientRegistry, ClientSnapshot}; pub use client::{ClientEvent, ClientRegistry, ClientSnapshot};
pub use sso::{SsoConfig, SsoStore};
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig}; pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
pub use error::{Error, Result}; pub use error::{Error, Result};
pub use host_bindings::{normalize_mac, HostBinding, HostBindings}; pub use host_bindings::{normalize_mac, HostBinding, HostBindings};
+318
View File
@@ -0,0 +1,318 @@
//! SAML SSO configuration — FleetDM-shaped, storage-only for v0.4.5.
//!
//! The operator pastes their IdP's metadata XML (or its URL) and a
//! human-readable label; v0.4.5 just persists it. The actual SAML
//! response-validation / JIT-provisioning flow lands in a later release
//! — for now we cover the "configurable" half so an operator can teach
//! OpenPXE about their IdP today and flip the switch on next upgrade.
//!
//! Shape borrowed from <https://github.com/fleetdm/fleet>'s app-config
//! SSO block, minus the user-RBAC fields (OpenPXE is single-tier: you
//! have access or you don't). Entity ID is omitted from the operator
//! UI per the v0.4.5 brief — it defaults to the advertised public base
//! URL when SAML wiring lands, which is what most IdPs expect anyway.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use crate::{Error, Result};
/// The configurable surface. `metadata` and `metadata_url` are mutually
/// exclusive at apply time (one or the other identifies the IdP); the
/// store keeps both fields so an operator can switch between them
/// without losing the inactive one.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SsoConfig {
/// Master switch — when false, all SSO machinery (planned for a
/// later release) is skipped regardless of the rest of the fields.
#[serde(default)]
pub enabled: bool,
/// Display name shown on the WebUI's login screen as the "Sign in
/// with X" button label. Empty/whitespace falls back to "SSO".
#[serde(default)]
pub idp_name: String,
/// Optional HTTPS URL pointing at the IdP's brand logo. Rendered
/// next to `idp_name` on the WebUI's login screen (FleetDM-style).
/// Length-capped at [`MAX_URL_LEN`]; empty is fine.
#[serde(default)]
pub idp_logo_url: String,
/// Raw SAML metadata XML pasted by the operator. Mutually exclusive
/// with `metadata_url`; if both are set, the URL wins at apply time
/// (operators typically forget about a stale XML paste).
#[serde(default)]
pub metadata: String,
/// HTTPS URL where the IdP serves its metadata. Loaded lazily by the
/// future SAML flow; not validated here beyond a basic length cap.
#[serde(default)]
pub metadata_url: String,
}
impl SsoConfig {
/// Returns `true` only when the config is *usable* — enabled, and
/// at least one of metadata/metadata_url is present. The future
/// login flow will key off this; for v0.4.5 the WebUI uses it to
/// surface a yellow "configured but not live yet" hint.
#[must_use]
pub fn is_usable(&self) -> bool {
self.enabled
&& (!self.metadata.trim().is_empty() || !self.metadata_url.trim().is_empty())
}
}
/// In-memory + on-disk SSO settings registry.
#[derive(Debug, Clone)]
pub struct SsoStore {
path: Arc<PathBuf>,
inner: Arc<RwLock<SsoConfig>>,
}
impl SsoStore {
/// Load from `<work_dir>/sso.json`, or start with the default empty
/// (`enabled = false`) config. A corrupt file falls back to default
/// rather than blocking startup.
#[must_use]
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("sso.json");
let cfg = match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str::<SsoConfig>(&text) {
Ok(parsed) => parsed,
Err(e) => {
tracing::warn!(
target: "openpxe::sso",
"sso.json present but unreadable ({e}); starting with default config"
);
SsoConfig::default()
}
},
Err(_) => SsoConfig::default(),
};
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(cfg)),
}
}
#[must_use]
pub fn snapshot(&self) -> SsoConfig {
self.inner.read().clone()
}
/// Replace the whole config in one shot. Light validation: metadata
/// XML and URL are length-capped so an operator can't OOM us by
/// pasting a 10 GiB blob; the IdP UI tab clamps the input visually,
/// but the server enforces a hard ceiling regardless.
pub fn replace(&self, mut cfg: SsoConfig) -> Result<SsoConfig> {
cfg.idp_name = cfg.idp_name.trim().to_string();
cfg.idp_logo_url = cfg.idp_logo_url.trim().to_string();
cfg.metadata = cfg.metadata.trim().to_string();
cfg.metadata_url = cfg.metadata_url.trim().to_string();
if cfg.metadata.len() > MAX_METADATA_BYTES {
return Err(Error::Invalid(format!(
"metadata XML exceeds {MAX_METADATA_BYTES}-byte cap"
)));
}
if cfg.metadata_url.len() > MAX_URL_LEN {
return Err(Error::Invalid(format!(
"metadata_url exceeds {MAX_URL_LEN}-char cap"
)));
}
if cfg.idp_logo_url.len() > MAX_URL_LEN {
return Err(Error::Invalid(format!(
"idp_logo_url exceeds {MAX_URL_LEN}-char cap"
)));
}
if !cfg.metadata_url.is_empty()
&& !cfg.metadata_url.starts_with("http://")
&& !cfg.metadata_url.starts_with("https://")
{
return Err(Error::Invalid(
"metadata_url must start with http:// or https://".into(),
));
}
if !cfg.idp_logo_url.is_empty()
&& !cfg.idp_logo_url.starts_with("http://")
&& !cfg.idp_logo_url.starts_with("https://")
{
return Err(Error::Invalid(
"idp_logo_url must start with http:// or https://".into(),
));
}
// If they're trying to *enable* the integration but haven't
// supplied either source, reject — saves a "configured but
// unusable" surprise later.
if cfg.enabled && cfg.metadata.is_empty() && cfg.metadata_url.is_empty() {
return Err(Error::Invalid(
"enable SSO requires either metadata XML or a metadata URL".into(),
));
}
{
let mut g = self.inner.write();
*g = cfg.clone();
}
self.persist();
tracing::info!(
target: "openpxe::sso",
enabled = cfg.enabled,
idp = %cfg.idp_name,
has_xml = !cfg.metadata.is_empty(),
has_url = !cfg.metadata_url.is_empty(),
"sso configuration updated"
);
Ok(cfg)
}
fn persist(&self) {
let snap = self.inner.read().clone();
let body = match serde_json::to_vec_pretty(&snap) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::sso", "serialize sso.json: {e}");
return;
}
};
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = self.path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::sso", "write sso.json tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::warn!(target: "openpxe::sso", "rename sso.json: {e}");
}
}
}
/// Saturation caps. The numbers are generous for any real IdP metadata
/// document — Okta's largest is ~50 KB, Azure AD's ~30 KB.
const MAX_METADATA_BYTES: usize = 1024 * 1024;
const MAX_URL_LEN: usize = 2048;
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn default_is_disabled_and_empty() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let cfg = s.snapshot();
assert!(!cfg.enabled);
assert!(cfg.metadata.is_empty());
assert!(cfg.metadata_url.is_empty());
assert!(!cfg.is_usable());
}
#[test]
fn replace_metadata_url_round_trip_via_disk() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
s.replace(SsoConfig {
enabled: true,
idp_name: "Okta".into(),
metadata: String::new(),
metadata_url: "https://idp.example.com/metadata".into(),
idp_logo_url: String::new(),
})
.unwrap();
drop(s);
let s2 = SsoStore::load_or_default(dir.path());
let cfg = s2.snapshot();
assert!(cfg.enabled);
assert!(cfg.is_usable());
assert_eq!(cfg.idp_name, "Okta");
assert_eq!(cfg.metadata_url, "https://idp.example.com/metadata");
}
#[test]
fn replace_xml_paste_is_accepted() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let xml = r#"<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata">test</EntityDescriptor>"#;
s.replace(SsoConfig {
enabled: true,
idp_name: "Test IdP".into(),
metadata: xml.into(),
metadata_url: String::new(),
idp_logo_url: String::new(),
})
.unwrap();
assert!(s.snapshot().is_usable());
}
#[test]
fn enable_without_source_is_rejected() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let r = s.replace(SsoConfig {
enabled: true,
idp_name: "Okta".into(),
metadata: String::new(),
metadata_url: String::new(),
idp_logo_url: String::new(),
});
assert!(matches!(r, Err(Error::Invalid(_))));
// …and a disabled blank config is fine.
s.replace(SsoConfig::default()).unwrap();
}
#[test]
fn metadata_url_must_be_http_scheme() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let r = s.replace(SsoConfig {
enabled: false,
idp_name: String::new(),
metadata: String::new(),
metadata_url: "ftp://idp.example.com/metadata".into(),
idp_logo_url: String::new(),
});
assert!(matches!(r, Err(Error::Invalid(_))));
}
#[test]
fn idp_logo_url_must_be_http_scheme() {
// v0.4.6: SSO settings learned an idp_logo_url so the login
// screen can render the FleetDM-style "Sign in with <IdP-logo>"
// affordance. Same scheme rule as metadata_url.
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let r = s.replace(SsoConfig {
enabled: false,
idp_name: "Okta".into(),
metadata: String::new(),
metadata_url: String::new(),
idp_logo_url: "data:image/png;base64,...".into(),
});
assert!(matches!(r, Err(Error::Invalid(_))));
// Real HTTPS URL is fine.
s.replace(SsoConfig {
enabled: false,
idp_name: "Okta".into(),
metadata: String::new(),
metadata_url: String::new(),
idp_logo_url: "https://idp.example.com/logo.png".into(),
})
.unwrap();
assert_eq!(s.snapshot().idp_logo_url, "https://idp.example.com/logo.png");
}
#[test]
fn metadata_size_cap_enforced() {
let dir = tempdir().unwrap();
let s = SsoStore::load_or_default(dir.path());
let oversize = "a".repeat(MAX_METADATA_BYTES + 1);
let r = s.replace(SsoConfig {
enabled: false,
idp_name: String::new(),
metadata: oversize,
metadata_url: String::new(),
idp_logo_url: String::new(),
});
assert!(matches!(r, Err(Error::Invalid(_))));
}
}
+6
View File
@@ -31,6 +31,9 @@ bytes.workspace = true
futures.workspace = true futures.workspace = true
mime.workspace = true mime.workspace = true
mime_guess.workspace = true mime_guess.workspace = true
uuid.workspace = true
# v0.4.5 Forms auth: lock-free session store and cookie helpers.
parking_lot.workspace = true
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] } tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] }
@@ -38,3 +41,6 @@ tower = { workspace = true }
tempfile = "3.12" tempfile = "3.12"
serde_json = { workspace = true } serde_json = { workspace = true }
time = { workspace = true } time = { workspace = true }
# v0.4.61: integration tests need to generate real PNG bytes for the
# `/branding/pxe-logo` compositor; hand-rolled CRCs are too fragile.
image = { version = "0.25", default-features = false, features = ["png"] }
File diff suppressed because it is too large Load Diff
+485
View File
@@ -0,0 +1,485 @@
//! Forms auth layer — sessions, login, setup, middleware.
//!
//! Three states:
//!
//! * **Unconfigured** (`AdminStore::is_configured() == false`). The
//! middleware passes every request through — there's no one to gate
//! against. The UI's `/api/me` returns `setup_required: true` and the
//! front-end pushes the operator into the first-run flow.
//! * **Logged in**. The session cookie maps to an in-memory session
//! record with an idle expiry; `/api/me` returns the username.
//! * **Logged out**. The middleware bounces `/api/*` (with the PXE
//! allowlist below) to `401 Unauthorized`; the front-end intercepts
//! that and shows `/login`.
//!
//! Allowlist for unauthenticated access *after* the admin is set up:
//!
//! * everything outside `/api/*` (the WebUI bundle, asset chrome, PXE
//! script endpoints, the bundled iPXE/wimboot binaries, ISO bytes,
//! liveness/readiness probes, the Prometheus scrape) — these are
//! read-only or PXE-essential and breaking them locks out booting
//! machines that have no way to authenticate;
//! * `/api/setup`, `/api/login`, `/api/me` (the auth surface itself);
//! * `/api/queue/join`, `/api/queue/poll/:entry_id` (iPXE long-poll for
//! Queued Deployment — the iPXE client can't send a session cookie).
//!
//! Everything else inside `/api/*` requires a valid session.
use crate::state::AppState;
use axum::{
body::Body,
extract::{Request, State},
http::{header, HeaderValue, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Json,
};
use openpxe_core::AdminPublic;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use uuid::Uuid;
/// Idle session lifetime. Sliding — every authenticated request resets
/// the expiry. 24h is the Sonarr default and matches what most operators
/// expect for an on-prem admin console.
const SESSION_TTL: Duration = Duration::from_hours(24);
/// Name of the cookie we set/read. Distinct from a generic `session=`
/// to avoid collisions with anything else sharing the host.
pub const SESSION_COOKIE: &str = "openpxe_session";
#[derive(Debug, Clone)]
struct Session {
username: String,
expires_at: Instant,
}
/// In-memory session table. Cheap to clone (Arc-shared) and contention
/// is rare — operators sign in once per browser session.
#[derive(Debug, Clone, Default)]
pub struct SessionStore {
inner: Arc<RwLock<HashMap<String, Session>>>,
}
impl SessionStore {
/// Mint a fresh session for `username` and return the opaque cookie
/// value. UUID v4 gives us 122 random bits — comfortably more than
/// the 64-128 bits typical for session IDs.
#[must_use]
pub fn create(&self, username: &str) -> String {
let id = Uuid::new_v4().simple().to_string();
let session = Session {
username: username.to_string(),
expires_at: Instant::now() + SESSION_TTL,
};
self.inner.write().insert(id.clone(), session);
id
}
/// Resolve a cookie value to the owning username, refreshing the
/// idle timer. Returns `None` for missing / expired sessions and
/// proactively evicts the expired entry so the map doesn't grow
/// unbounded across long-lived deployments.
pub fn touch(&self, id: &str) -> Option<String> {
let mut g = self.inner.write();
let s = g.get_mut(id)?;
if s.expires_at <= Instant::now() {
g.remove(id);
return None;
}
s.expires_at = Instant::now() + SESSION_TTL;
Some(s.username.clone())
}
/// Invalidate one session (the user's `/api/logout`).
pub fn revoke(&self, id: &str) {
self.inner.write().remove(id);
}
/// Invalidate every session — used after a credentials rotation so
/// stale cookies for the old password can't keep operating.
pub fn revoke_all(&self) {
self.inner.write().clear();
}
/// Periodic / opportunistic GC. Not currently scheduled (we evict
/// on touch), but exposed for a future janitor task.
pub fn gc(&self) {
let now = Instant::now();
self.inner.write().retain(|_, s| s.expires_at > now);
}
#[must_use]
pub fn len(&self) -> usize {
self.inner.read().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
// ── Cookie helpers ────────────────────────────────────────────────────────
fn cookie_attrs(value: &str, max_age: Option<i64>) -> String {
// Same flags FleetDM and Sonarr ship by default:
// - HttpOnly: blocks JS access (XSS containment)
// - SameSite=Lax: allows top-level GET navigations from the IdP
// to land authenticated when SSO arrives, but blocks
// cross-site POST CSRF;
// - Path=/: the cookie applies to the whole app;
// - no Secure flag yet — many operators host on plain http://
// LAN IPs (Unraid templates default to that); we'll add Secure
// opportunistically when we add a TLS terminator option.
// SESSION_TTL fits in 32 bits comfortably (24h ≈ 86400 seconds); we
// never overflow i64, but clippy's `cast_possible_wrap` lint wants
// us to be explicit. `cast_signed` is the documented form.
let lifetime = max_age.unwrap_or_else(|| SESSION_TTL.as_secs().cast_signed());
format!(
"{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={lifetime}"
)
}
fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
// `Cookie: a=b; c=d` parsing — small enough not to drag in a crate.
let raw = headers.get(header::COOKIE)?.to_str().ok()?;
for part in raw.split(';') {
let part = part.trim();
if let Some(v) = part.strip_prefix(&format!("{SESSION_COOKIE}=")) {
return Some(v.to_string());
}
}
None
}
// ── Middleware ────────────────────────────────────────────────────────────
/// Return `true` if `path` is on the allowlist and should bypass the
/// session check. The middleware applies this rule only when the admin
/// account is configured; before then everything is open.
fn is_public_path(path: &str) -> bool {
// Non-API paths: WebUI bundle, PXE chain, ISO bytes, health probes,
// metrics. All read-only / PXE-essential.
if !path.starts_with("/api/") {
return true;
}
// Auth surface and iPXE long-poll endpoints (no cookie available).
matches!(
path,
"/api/setup" | "/api/login" | "/api/logout" | "/api/me"
) || path.starts_with("/api/queue/join")
|| path.starts_with("/api/queue/poll/")
}
/// Axum middleware: gate `/api/*` behind a valid session, with the
/// allowlist above. `State<AppState>` reaches in for the admin store +
/// session store.
pub async fn require_auth(
State(state): State<AppState>,
req: Request<Body>,
next: Next,
) -> Response {
// Bypass entirely while unconfigured. The /api/setup endpoint is
// the only one that can flip this back to "configured", and it
// refuses to run a second time. Tests + fresh installs ride this
// path.
if !state.admin.is_configured() {
return next.run(req).await;
}
let path = req.uri().path();
if is_public_path(path) {
return next.run(req).await;
}
// Authenticated path. The cookie must be present, map to a live
// session, and the TTL refresh happens as a side-effect.
let token = parse_cookie(req.headers());
if let Some(t) = token {
if state.sessions.touch(&t).is_some() {
return next.run(req).await;
}
}
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "authentication required" })),
)
.into_response()
}
// ── Handlers ──────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct SetupBody {
pub username: String,
pub password: String,
}
/// First-run setup. Refuses to run once an admin already exists — that
/// guards against a leaked WebUI being re-bootstrapped by an attacker
/// who's seen the deployment URL. After bootstrap, the new session
/// cookie is set so the operator goes straight to the dashboard.
pub async fn api_setup(
State(state): State<AppState>,
Json(body): Json<SetupBody>,
) -> Response {
if state.admin.is_configured() {
return (
StatusCode::CONFLICT,
Json(json!({ "error": "admin account already configured" })),
)
.into_response();
}
match state.admin.bootstrap(&body.username, &body.password) {
Ok(pub_) => {
let session = state.sessions.create(&pub_.username);
login_response(StatusCode::CREATED, &pub_, &session)
}
Err(openpxe_core::Error::Invalid(msg)) => {
(StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("{e}") })),
)
.into_response(),
}
}
#[derive(Debug, Deserialize)]
pub struct LoginBody {
pub username: String,
pub password: String,
}
pub async fn api_login(State(state): State<AppState>, Json(body): Json<LoginBody>) -> Response {
// Brief, deliberately vague — "invalid credentials" rather than
// "no such user" / "wrong password". Same anti-enumeration posture
// as Sonarr/Radarr.
let pub_ = match state.admin.verify(&body.username, &body.password) {
Ok(Some(u)) => u,
Ok(None) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "invalid username or password" })),
)
.into_response();
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("{e}") })),
)
.into_response();
}
};
let session = state.sessions.create(&pub_.username);
login_response(StatusCode::OK, &pub_, &session)
}
pub async fn api_logout(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
) -> Response {
if let Some(t) = parse_cookie(&headers) {
state.sessions.revoke(&t);
}
// Stomp the cookie unconditionally — even if the request didn't
// carry one, the browser shouldn't keep a stale value.
let mut resp = StatusCode::NO_CONTENT.into_response();
resp.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_str(&cookie_attrs("", Some(0))).unwrap(),
);
resp
}
/// Status surface for the front-end shell. Returns four cases:
///
/// * `setup_required: true` — no admin yet; show first-run page.
/// * `authenticated: false` — admin exists, no session; show login.
/// * `authenticated: true` + `user` — let the dashboard load.
pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
if !state.admin.is_configured() {
return (
StatusCode::OK,
Json(json!({
"setup_required": true,
"authenticated": false,
})),
)
.into_response();
}
let token = parse_cookie(&headers);
let username = token.as_deref().and_then(|t| state.sessions.touch(t));
match username {
Some(u) => (
StatusCode::OK,
Json(json!({
"setup_required": false,
"authenticated": true,
"user": state.admin.snapshot(),
"session_user": u,
})),
)
.into_response(),
None => (
StatusCode::OK,
Json(json!({
"setup_required": false,
"authenticated": false,
})),
)
.into_response(),
}
}
#[derive(Debug, Deserialize)]
pub struct UpdateCredentialsBody {
pub current_password: String,
#[serde(default)]
pub new_username: Option<String>,
#[serde(default)]
pub new_password: Option<String>,
}
/// Rotate the admin's username and/or password. Auth middleware has
/// already proved the caller owns a session; we additionally require
/// the *current* password to prove "person at the keyboard right now".
/// On success we issue a fresh session cookie keyed to the (possibly
/// new) username and revoke every prior session so a stolen cookie
/// from before the rotation stops working.
pub async fn api_update_credentials(
State(state): State<AppState>,
Json(body): Json<UpdateCredentialsBody>,
) -> Response {
if !state.admin.is_configured() {
return (
StatusCode::CONFLICT,
Json(json!({ "error": "no admin configured" })),
)
.into_response();
}
let result = state.admin.update_credentials(
&body.current_password,
body.new_username.as_deref(),
body.new_password.as_deref(),
);
match result {
Ok(pub_) => {
state.sessions.revoke_all();
let session = state.sessions.create(&pub_.username);
login_response(StatusCode::OK, &pub_, &session)
}
Err(openpxe_core::Error::Invalid(msg)) => {
(StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("{e}") })),
)
.into_response(),
}
}
#[derive(Debug, Serialize)]
struct LoginPayload<'a> {
user: &'a AdminPublic,
authenticated: bool,
}
fn login_response(status: StatusCode, user: &AdminPublic, session: &str) -> Response {
let body = Json(LoginPayload {
user,
authenticated: true,
});
let mut resp = (status, body).into_response();
resp.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_str(&cookie_attrs(session, None)).unwrap(),
);
resp
}
// ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_create_touch_revoke() {
let s = SessionStore::default();
assert!(s.is_empty());
let t = s.create("admin");
assert_eq!(s.len(), 1);
assert_eq!(s.touch(&t).as_deref(), Some("admin"));
s.revoke(&t);
assert!(s.is_empty());
// Stale token doesn't error, just returns None.
assert!(s.touch(&t).is_none());
}
#[test]
fn session_revoke_all_clears() {
let s = SessionStore::default();
let _ = s.create("a");
let _ = s.create("b");
assert_eq!(s.len(), 2);
s.revoke_all();
assert!(s.is_empty());
}
#[test]
fn public_path_allowlist() {
// PXE + chrome paths bypass auth.
for p in [
"/", "/assets/app.js", "/boot.ipxe", "/boot/fake.ipxe",
"/iso/fake.iso", "/ipxe/snponly.efi", "/healthz", "/readyz",
"/metrics",
// v0.4.6: iPXE fetches this for `console --picture` before
// it can possibly have a session cookie.
"/branding/pxe-logo",
] {
assert!(is_public_path(p), "expected {p} to be public");
}
// Auth surface itself is public.
for p in ["/api/setup", "/api/login", "/api/logout", "/api/me"] {
assert!(is_public_path(p), "expected {p} to be public");
}
// iPXE long-poll endpoints are public (no cookie available).
assert!(is_public_path("/api/queue/join"));
assert!(is_public_path("/api/queue/poll/abc"));
// Everything else under /api/* must auth.
for p in [
"/api/isos",
"/api/isos/x/category",
"/api/storage/disk",
"/api/branding/logo",
"/api/sso",
"/api/hosts",
] {
assert!(!is_public_path(p), "expected {p} to require auth");
}
}
#[test]
fn cookie_parse_picks_session_value() {
let mut h = axum::http::HeaderMap::new();
h.insert(
header::COOKIE,
HeaderValue::from_str(&format!("foo=bar; {SESSION_COOKIE}=abc123; baz=qux"))
.unwrap(),
);
assert_eq!(parse_cookie(&h).as_deref(), Some("abc123"));
// Different name → None.
let mut h2 = axum::http::HeaderMap::new();
h2.insert(header::COOKIE, HeaderValue::from_str("foo=bar").unwrap());
assert!(parse_cookie(&h2).is_none());
// No cookie header at all → None.
assert!(parse_cookie(&axum::http::HeaderMap::new()).is_none());
}
}
+168 -6
View File
@@ -29,6 +29,15 @@ use std::fmt::Write as _;
/// Top-level OpenPXE boot menu. Serialized identically for BIOS and UEFI /// Top-level OpenPXE boot menu. Serialized identically for BIOS and UEFI
/// clients because iPXE normalises the menu primitives across firmwares. /// clients because iPXE normalises the menu primitives across firmwares.
///
/// v0.4.6: rendered with an iVentoy-style polished frame — centered
/// OpenPXE wordmark banner at the top (ASCII so every iPXE build can
/// paint it), a footer carrying version + arch + firmware kind, and an
/// optional `console --picture` directive that paints the operator's
/// uploaded raster logo on top when the iPXE binary on the wire was
/// built with PNG support. The ASCII banner is always rendered so
/// even when the picture call no-ops the screen still reads as
/// "OpenPXE — here is the menu" rather than a featureless box.
#[must_use] #[must_use]
pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String { pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String {
let mut s = String::new(); let mut s = String::new();
@@ -47,8 +56,41 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
let _ = writeln!(s, "set base-url {base}"); let _ = writeln!(s, "set base-url {base}");
let _ = writeln!(s, "set esc:hex 1b"); let _ = writeln!(s, "set esc:hex 1b");
let _ = writeln!(s, "set cls ${{esc:string}}[2J"); let _ = writeln!(s, "set cls ${{esc:string}}[2J");
// v0.4.6: best-effort graphics console with the operator-uploaded
// raster logo. Falls back to plain text console on iPXE builds
// without PNG support — the `||` chain keeps a parse-clean
// single-statement form so even the strictest iPXE parsers accept
// it. The `console` reset at the end re-syncs the menu output.
let _ = writeln!(
s,
"console --picture {base}/branding/pxe-logo || console"
);
// Map iPXE's ${{buildarch}} + ${{platform}} into the human form the
// user asked for (e.g. "x86 BIOS", "x86_64 UEFI", "arm64 UEFI").
// iPXE evaluates `iseq` lazily, so we only set whichever line
// matches. Anything not on the allowlist falls through to a generic
// `<buildarch> <platform>` display.
let _ = writeln!(s, "set arch-label ${{buildarch}} ${{platform}}");
let _ = writeln!(
s,
"iseq ${{buildarch}} i386 && iseq ${{platform}} pcbios && set arch-label x86 BIOS || iseq ${{buildarch}} x86_64 && iseq ${{platform}} efi && set arch-label x86_64 UEFI || iseq ${{buildarch}} arm64 && iseq ${{platform}} efi && set arch-label arm64 UEFI || true"
);
let _ = writeln!(s, ":menu"); let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu OpenPXE - network boot menu"); let _ = writeln!(s, "menu OpenPXE - network boot menu");
// ASCII OpenPXE wordmark. Works on every iPXE build, including
// the boot.ipxe.org pre-builds we ship (which omit `IMAGE_PNG`,
// so `console --picture` paints nothing). When the queued iPXE
// source-build lands and the operator's uploaded raster actually
// paints via `console --picture`, this banner can be retired in
// favour of the real image. The compositor at
// /branding/pxe-logo is already wired and waiting.
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --gap -- ___ ___ __ __ ___");
let _ = writeln!(s, "item --gap -- / _ \\ _ __ ___ _ _ | _ \\ \\/ / | __|");
let _ = writeln!(s, "item --gap -- | (_) | '_ \\/ -_) ' \\ | _/ \\ / | _|");
let _ = writeln!(s, "item --gap -- \\___/| .__/\\___|_||_| |_| /_/\\_\\ |___|");
let _ = writeln!(s, "item --gap -- |_|");
let _ = writeln!(s, "item --gap");
let _ = writeln!( let _ = writeln!(
s, s,
"item --gap -- ------------------------- Default -------------------------" "item --gap -- ------------------------- Default -------------------------"
@@ -82,6 +124,18 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
let _ = writeln!(s, "item queue Queued Deployment (join queue)"); let _ = writeln!(s, "item queue Queued Deployment (join queue)");
let _ = writeln!(s, "item --gap"); let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key x exit Exit iPXE"); let _ = writeln!(s, "item --key x exit Exit iPXE");
// v0.4.6 footer line. Sits just above the `choose` line so it's
// always visible regardless of how the menu paginates. iPXE
// interpolates `${arch-label}` (set near the top of this script)
// and `${version}` is the binary-baked iPXE version — *not* the
// OpenPXE version — so we hard-code the OpenPXE version string
// here.
let openpxe_version = env!("CARGO_PKG_VERSION");
let _ = writeln!(s, "item --gap");
let _ = writeln!(
s,
"item --gap -- OpenPXE v{openpxe_version} - ${{arch-label}}"
);
if matches!(settings.timeout_action, TimeoutAction::Stay) { if matches!(settings.timeout_action, TimeoutAction::Stay) {
let _ = writeln!(s, "choose --default {default_item} target || goto menu"); let _ = writeln!(s, "choose --default {default_item} target || goto menu");
@@ -149,6 +203,13 @@ pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) ->
if !filter(iso.introspection.family) { if !filter(iso.introspection.family) {
continue; continue;
} }
// v0.4.4: ISOs the operator flipped to the Tools category move
// out of the OS installer submenus entirely — they only appear
// under Tools. Without this filter the operator would see the
// same ISO in both menus.
if matches!(iso.category, openpxe_iso_store::IsoCategory::Tools) {
continue;
}
for entry in &iso.boot_entries { for entry in &iso.boot_entries {
let size_label = fmt_size_mib(iso.size_bytes); let size_label = fmt_size_mib(iso.size_bytes);
let key = hotkey_for_index(count); let key = hotkey_for_index(count);
@@ -182,7 +243,14 @@ pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) ->
s, s,
"iseq ${{target}} back && chain {base}/boot.ipxe || goto menu" "iseq ${{target}} back && chain {base}/boot.ipxe || goto menu"
); );
let _ = writeln!(s, "chain {base}/boot/${{target}}.ipxe || goto menu"); // Pass `?mac=${mac}` so the per-entry handler can record the booting
// client into the Host log. iPXE substitutes `${mac}` before
// the HTTP fetch; if the firmware can't resolve it the literal
// `${mac}` is sent and the server treats it as "unknown".
let _ = writeln!(
s,
"chain {base}/boot/${{target}}.ipxe?mac=${{mac}} || goto menu"
);
s s
} }
@@ -203,15 +271,52 @@ fn hotkey_for_index(i: usize) -> String {
} }
} }
/// Tools submenu — Utilities, Shell, NIC Info, Reboot, Exit to firmware. /// Tools submenu — Utilities, Shell, NIC Info, Reboot, Exit to firmware,
/// plus any ISOs the operator flipped to [`IsoCategory::Tools`] in the
/// Storage tab. The category-Tools ISOs render first so frequently used
/// recovery / hardware tools are reachable with a single number key
/// before the built-in shortcuts.
#[must_use] #[must_use]
pub fn render_tools_menu(base_url: &str) -> String { pub fn render_tools_menu(isos: &[IsoMeta], base_url: &str) -> String {
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
let mut s = String::new(); let mut s = String::new();
let _ = writeln!(s, "#!ipxe"); let _ = writeln!(s, "#!ipxe");
let _ = writeln!(s, "set base-url {base}"); let _ = writeln!(s, "set base-url {base}");
let _ = writeln!(s, ":menu"); let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu OpenPXE - Tools"); let _ = writeln!(s, "menu OpenPXE - Tools");
// Operator-categorized tool ISOs (hotkeys 1..9), each chained the
// same way as a per-family menu pick — through the boot-entry id
// route, carrying `?mac=${mac}` for Host log attribution.
let mut count = 0;
for iso in isos {
if !matches!(iso.category, openpxe_iso_store::IsoCategory::Tools) {
continue;
}
for entry in &iso.boot_entries {
let size_label = fmt_size_mib(iso.size_bytes);
let key = hotkey_for_index(count);
let lock = if iso.is_password_protected() {
"*"
} else {
" "
};
let _ = writeln!(
s,
"item {}{} {}[{:>6}] {}",
key,
entry.id,
lock,
size_label,
escape_label(&entry.title),
);
count += 1;
}
}
if count > 0 {
let _ = writeln!(s, "item --gap");
}
let _ = writeln!(s, "item --key u util Utilities (memtest, ...)"); let _ = writeln!(s, "item --key u util Utilities (memtest, ...)");
let _ = writeln!(s, "item --key s shell OpenPXE Shell"); let _ = writeln!(s, "item --key s shell OpenPXE Shell");
let _ = writeln!(s, "item --key n nic Network Card Info"); let _ = writeln!(s, "item --key n nic Network Card Info");
@@ -245,7 +350,12 @@ pub fn render_tools_menu(base_url: &str) -> String {
s, s,
"iseq ${{target}} back && chain {base}/boot.ipxe || goto menu" "iseq ${{target}} back && chain {base}/boot.ipxe || goto menu"
); );
let _ = writeln!(s, "goto menu"); // Fall-through for category-Tools ISO ids — same as the family
// submenu, carrying `?mac=${mac}` for the boot log.
let _ = writeln!(
s,
"chain {base}/boot/${{target}}.ipxe?mac=${{mac}} || goto menu"
);
s s
} }
@@ -461,9 +571,14 @@ pub fn render_password_prompt(entry_id: &str, iso_filename: &str, base_url: &str
); );
let _ = writeln!(s, ":submit"); let _ = writeln!(s, ":submit");
let _ = writeln!(s, "echo Verifying..."); let _ = writeln!(s, "echo Verifying...");
// Carry `mac=${mac}` alongside the token so a successful unlock
// records the actual client MAC into the Host log. On
// older iPXE that can't resolve `${mac}` the server just stores it
// as "unknown" rather than refusing to boot.
let _ = writeln!( let _ = writeln!(
s, s,
"chain {base}/boot/{entry_id}.ipxe?token=${{password:uristring}} || chain {base}/boot.ipxe" "chain {base}/boot/{entry_id}.ipxe?token=${{password:uristring}}&mac=${{mac}} \
|| chain {base}/boot.ipxe"
); );
s s
} }
@@ -514,12 +629,59 @@ mod password_tests {
assert!(s.contains("chain http://10.0.0.5/boot/alpha-linux.ipxe")); assert!(s.contains("chain http://10.0.0.5/boot/alpha-linux.ipxe"));
} }
#[test]
fn top_menu_has_polished_branding_and_arch_footer() {
// v0.4.6 polish + v0.4.62 stability fixes: the menu emits a
// `console --picture` line that PNG-capable iPXE builds will
// honour (queued for a follow-up release once we can rebuild
// iPXE from source on native x86_64 hardware), an ASCII
// OpenPXE wordmark that works on every iPXE build (including
// the boot.ipxe.org pre-builds we currently ship), and a
// single-line footer carrying the OpenPXE version + arch.
let settings = Settings::default();
let s = render_menu(&[], &settings, "http://10.0.0.5");
assert!(
s.contains("console --picture http://10.0.0.5/branding/pxe-logo"),
"missing console --picture line:\n{s}"
);
// Picture-or-text-console must be a single statement so older
// iPXE parsers don't choke on the chain.
assert!(s.contains("|| console"), "missing graceful fallback:\n{s}");
// ASCII wordmark — paints on every iPXE build regardless of
// PNG support.
assert!(
s.contains("___ ___ __ __ ___"),
"ASCII banner missing first row:\n{s}"
);
// Footer with version + arch interpolation. The version comes
// from CARGO_PKG_VERSION at compile time.
let version = env!("CARGO_PKG_VERSION");
assert!(
s.contains(&format!("OpenPXE v{version}")),
"footer missing OpenPXE version:\n{s}"
);
assert!(
s.contains("${arch-label}"),
"footer missing arch-label interpolation:\n{s}"
);
// No website URL — the design brief calls that out as tacky.
assert!(
!s.to_ascii_lowercase().contains("openpxe.com"),
"footer should not advertise the website:\n{s}"
);
// Arch-label mapping covers the three labels from the brief:
// "x86 BIOS", "x86_64 UEFI", "arm64 UEFI".
assert!(s.contains("x86 BIOS"), "{s}");
assert!(s.contains("x86_64 UEFI"), "{s}");
assert!(s.contains("arm64 UEFI"), "{s}");
}
#[test] #[test]
fn generated_scripts_do_not_emit_bare_or_trailing_fallbacks() { fn generated_scripts_do_not_emit_bare_or_trailing_fallbacks() {
let settings = Settings::default(); let settings = Settings::default();
let scripts = [ let scripts = [
render_menu(&[], &settings, "http://10.0.0.5"), render_menu(&[], &settings, "http://10.0.0.5"),
render_tools_menu("http://10.0.0.5"), render_tools_menu(&[], "http://10.0.0.5"),
render_local_hdd("http://10.0.0.5"), render_local_hdd("http://10.0.0.5"),
render_util("http://10.0.0.5"), render_util("http://10.0.0.5"),
render_shell("http://10.0.0.5"), render_shell("http://10.0.0.5"),
+2
View File
@@ -14,11 +14,13 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
pub mod app; pub mod app;
pub mod auth;
pub mod ipxe_script; pub mod ipxe_script;
pub mod iso_fs; pub mod iso_fs;
pub mod log_stream; pub mod log_stream;
pub mod state; pub mod state;
pub mod terminal; pub mod terminal;
pub mod uploads;
pub use app::build_router; pub use app::build_router;
pub use state::AppState; pub use state::AppState;
+29 -1
View File
@@ -1,4 +1,9 @@
use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore}; use crate::uploads::UploadSessions;
use crate::auth::SessionStore;
use openpxe_core::{
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
Metrics, SettingsStore, SsoStore,
};
use openpxe_iso_store::{IsoStore, NfsManager, SmbManager}; use openpxe_iso_store::{IsoStore, NfsManager, SmbManager};
use std::sync::Arc; use std::sync::Arc;
use time::OffsetDateTime; use time::OffsetDateTime;
@@ -13,6 +18,25 @@ pub struct AppState {
/// these MACs requests `/boot.ipxe`, we chain straight to the /// these MACs requests `/boot.ipxe`, we chain straight to the
/// configured target instead of rendering the menu. /// configured target instead of rendering the menu.
pub hosts: HostBindings, pub hosts: HostBindings,
/// Persistent boot-event log surfaced under the Hosts tab. Records
/// every `/boot/<entry>.ipxe` chain that goes on to serve a script
/// (i.e. an image actually starting to install on a machine).
pub boot_log: BootLog,
/// Operator-controlled UI overrides (custom logo). When the
/// operator hasn't uploaded anything, the WebUI serves the bundled
/// rainbow-horizon mark.
pub branding: BrandingStore,
/// Forms-auth admin record + first-run bootstrap state. When
/// `admin.is_configured() == false`, the auth middleware passes
/// every request through and `/api/me` reports `setup_required`.
pub admin: AdminStore,
/// In-memory session table for active operator logins. Cleared on
/// process restart (sessions are tied to UI state, not persisted —
/// matches Sonarr/Radarr behaviour).
pub sessions: SessionStore,
/// SAML SSO configuration. v0.4.5 stores it; the actual SSO login
/// flow ships in a later release.
pub sso: SsoStore,
/// Lock-free metrics counters surfaced at `/metrics` in Prometheus /// Lock-free metrics counters surfaced at `/metrics` in Prometheus
/// text format. Cheap to clone (handles to atomics). /// text format. Cheap to clone (handles to atomics).
pub metrics: Metrics, pub metrics: Metrics,
@@ -25,6 +49,10 @@ pub struct AppState {
/// available in the runtime image. Surfaces errors per-mount rather /// available in the runtime image. Surfaces errors per-mount rather
/// than failing the global state. /// than failing the global state.
pub nfs: NfsManager, pub nfs: NfsManager,
/// Browser chunked upload state. Multipart uploads still go straight
/// through `IsoStore`, but the UI uses sessions so large ISO transfers
/// can show deterministic progress and leave visible partial files.
pub uploads: UploadSessions,
/// Live log bus consumed by the Terminal tab via SSE. Operator-issued /// Live log bus consumed by the Terminal tab via SSE. Operator-issued
/// terminal commands also push synthetic lines onto it so the tail /// terminal commands also push synthetic lines onto it so the tail
/// shows them inline. /// shows them inline.
+16 -1
View File
@@ -321,10 +321,25 @@ async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
export: export.to_string(), export: export.to_string(),
version, version,
read_only, read_only,
// v0.4.64: terminal callers can't override the port yet
// — keep the default 2049. We could plumb a 4th arg
// later if anyone asks.
port: None,
}; };
match s.nfs.add(req).await { match s.nfs.add(req).await {
Ok(m) => Ok(format!("mounted {} ({} isos)", m.id, m.iso_count)), Ok(m) => Ok(format!("mounted {} ({} isos)", m.id, m.iso_count)),
Err(e) => Err(format!("mount failed: {e}")), // v0.4.64: `add` now returns a structured `NfsMountError`.
// We render the raw error plus the hint (if any) on
// separate lines so the terminal output mirrors what
// the Storage tab shows.
Err(e) => {
let mut out = format!("mount failed: {}", e.error);
if let Some(h) = e.hint {
out.push_str("\nhint: ");
out.push_str(&h);
}
Err(out)
}
} }
} }
Some("unmount") => { Some("unmount") => {
+180
View File
@@ -0,0 +1,180 @@
//! Chunked upload sessions for browser-driven ISO uploads.
//!
//! The legacy multipart endpoint still exists for simple API clients, but
//! browsers get a better failure mode with raw chunks: progress advances after
//! each acknowledged write, partial files appear in the ISO directory
//! immediately, and reverse proxies are less likely to buffer an entire DVD
//! image before OpenPXE sees byte one.
use bytes::Bytes;
use openpxe_core::{Error, Result};
use openpxe_iso_store::{IsoMeta, IsoStore, UploadHandle};
use serde::Serialize;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use uuid::Uuid;
const DEFAULT_CHUNK_SIZE: u64 = 8 * 1024 * 1024;
#[derive(Clone, Default)]
pub struct UploadSessions {
inner: Arc<Mutex<HashMap<String, Arc<Mutex<UploadSession>>>>>,
}
struct UploadSession {
filename: String,
expected_size: Option<u64>,
offset: u64,
handle: Option<UploadHandle>,
}
#[derive(Debug, Clone, Serialize)]
pub struct UploadStarted {
pub upload_id: String,
pub iso_id: String,
pub filename: String,
pub offset: u64,
pub chunk_size: u64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum UploadAppend {
Progress { offset: u64 },
Complete { offset: u64, iso: Box<IsoMeta> },
}
impl UploadSessions {
pub async fn begin(
&self,
store: &IsoStore,
filename: &str,
expected_size: Option<u64>,
) -> Result<UploadStarted> {
if !filename.to_ascii_lowercase().ends_with(".iso") {
return Err(Error::Invalid("only .iso uploads accepted".to_string()));
}
let handle = store.begin_upload(filename).await?;
let iso_id = handle.id.clone();
let upload_id = Uuid::new_v4().to_string();
let session = UploadSession {
filename: filename.to_string(),
expected_size,
offset: 0,
handle: Some(handle),
};
self.inner
.lock()
.await
.insert(upload_id.clone(), Arc::new(Mutex::new(session)));
Ok(UploadStarted {
upload_id,
iso_id,
filename: filename.to_string(),
offset: 0,
chunk_size: DEFAULT_CHUNK_SIZE,
})
}
pub async fn append(
&self,
store: &IsoStore,
upload_id: &str,
offset: u64,
chunk: Bytes,
complete: bool,
) -> Result<UploadAppend> {
let Some(session_lock) = self.inner.lock().await.get(upload_id).cloned() else {
return Err(Error::Invalid(format!("no such upload '{upload_id}'")));
};
let mut session = session_lock.lock().await;
if session.offset != offset {
return Err(Error::Invalid(format!(
"expected offset {}, got {offset}",
session.offset
)));
}
let new_offset = session
.offset
.checked_add(chunk.len() as u64)
.ok_or_else(|| Error::Invalid("upload offset overflow".to_string()))?;
if let Some(expected) = session.expected_size {
if new_offset > expected {
return Err(Error::Invalid(format!(
"chunk exceeds declared upload size {expected}"
)));
}
}
let Some(handle) = session.handle.as_mut() else {
return Err(Error::Invalid("upload already completed".to_string()));
};
if let Err(e) = handle.write_chunk(&chunk).await {
let handle = session.handle.take();
drop(session);
self.inner.lock().await.remove(upload_id);
if let Some(handle) = handle {
let _ = handle.abort().await;
}
return Err(e);
}
session.offset = new_offset;
if !complete {
return Ok(UploadAppend::Progress { offset: new_offset });
}
if let Some(expected) = session.expected_size {
if new_offset != expected {
return Err(Error::Invalid(format!(
"final chunk ended at {new_offset}, expected {expected}"
)));
}
}
let Some(handle) = session.handle.take() else {
return Err(Error::Invalid("upload already completed".to_string()));
};
let filename = session.filename.clone();
drop(session);
tracing::info!(
target: "openpxe::http::upload",
upload_id,
filename = %filename,
received_bytes = new_offset,
"chunked upload body complete; introspecting"
);
let meta = match handle.finish(store).await {
Ok(meta) => meta,
Err(e) => {
self.inner.lock().await.remove(upload_id);
return Err(e);
}
};
self.inner.lock().await.remove(upload_id);
Ok(UploadAppend::Complete {
offset: new_offset,
iso: Box::new(meta),
})
}
pub async fn abort(&self, upload_id: &str) -> Result<()> {
let Some(session_lock) = self.inner.lock().await.remove(upload_id) else {
return Err(Error::Invalid(format!("no such upload '{upload_id}'")));
};
let mut session = session_lock.lock().await;
if let Some(handle) = session.handle.take() {
handle.abort().await?;
}
Ok(())
}
}
+883
View File
@@ -98,6 +98,11 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
iso_store.set_nfs_root(nfs.mount_root()); iso_store.set_nfs_root(nfs.mount_root());
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 branding = openpxe_core::BrandingStore::load_or_default(dir.path());
let admin = openpxe_core::AdminStore::load_or_default(dir.path());
let sso = openpxe_core::SsoStore::load_or_default(dir.path());
let sessions = openpxe_http_api::auth::SessionStore::default();
let metrics = Metrics::new(); let metrics = Metrics::new();
let state = AppState { let state = AppState {
iso_store, iso_store,
@@ -105,9 +110,15 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
queue, queue,
settings, settings,
hosts, hosts,
boot_log,
branding,
admin,
sessions,
sso,
metrics, metrics,
smb: None, smb: None,
nfs, nfs,
uploads: openpxe_http_api::uploads::UploadSessions::default(),
log_bus, log_bus,
started_at: time::OffsetDateTime::now_utc(), started_at: time::OffsetDateTime::now_utc(),
public_base_url: "http://127.0.0.1".into(), public_base_url: "http://127.0.0.1".into(),
@@ -975,3 +986,875 @@ async fn set_password_for_unknown_iso_returns_404() {
.unwrap(); .unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND); assert_eq!(res.status(), StatusCode::NOT_FOUND);
} }
#[tokio::test]
async fn boot_log_records_entry_serve_with_mac() {
// End-to-end: upload an ISO, fetch the entry's boot script with a
// MAC query param, then GET /api/boot-log and assert the event is
// there with the supplied mac.
let (state, _dir) = build_state().await;
let app = build_router(state);
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
let upload = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/isos")
.header("content-type", ct)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(upload.status(), StatusCode::CREATED);
// Fetch the per-entry script with ?mac=...
let (s, _) = get(&app, "/boot/fake-alpine-linux.ipxe?mac=AA:BB:CC:00:00:09").await;
assert_eq!(s, StatusCode::OK);
// The boot log should now contain exactly one entry, with the
// normalized MAC and our target id.
let (s, body) = get(&app, "/api/boot-log").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
let events = v["events"].as_array().expect("events");
assert_eq!(events.len(), 1);
let ev = &events[0];
assert_eq!(ev["target_id"], "fake-alpine-linux");
assert_eq!(ev["mac"], "aa:bb:cc:00:00:09"); // normalized
// Title should include the filename and entry title.
let title = ev["target_title"].as_str().unwrap();
assert!(title.contains("fake-alpine.iso"), "title was {title}");
}
#[tokio::test]
async fn boot_log_endpoint_empty_when_no_boots() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, body) = get(&app, "/api/boot-log").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(v["events"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn boot_log_does_not_record_reserved_menu_targets() {
// Reserved targets (_local, _queue, …) are operator console actions,
// not imaging events. The Hosts log skips them so it stays focused
// on "what got installed where".
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
// Bind a MAC to the _local shortcut and hit /boot.ipxe.
let body = r#"{"mac":"aa:bb:cc:00:00:11","target":"_local","label":"q"}"#;
let (s, _) = post_json(&app, "/api/hosts", body).await;
assert_eq!(s, StatusCode::CREATED);
let (s, _) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:11").await;
assert_eq!(s, StatusCode::OK);
let (_, body) = get(&app, "/api/boot-log").await;
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
v["events"].as_array().unwrap().is_empty(),
"reserved targets should not appear in boot log; got {v}"
);
}
#[tokio::test]
async fn upload_rejects_non_iso_filename_with_clear_message() {
// Sanity for the upload-logging path: a wrong extension should land
// a 400 with the human message rather than silently being eaten by
// the multipart loop. (No iso ends up in the store either.)
let (state, _dir) = build_state().await;
let app = build_router(state);
let (ct, body) = multipart_iso_body("not-an-iso.txt", b"hello world");
let res = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/isos")
.header("content-type", ct)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
let text = std::str::from_utf8(&body).unwrap();
assert!(text.contains("only .iso uploads accepted"), "got: {text}");
}
#[tokio::test]
async fn chunked_upload_writes_progressively_and_finishes_iso() {
let (state, dir) = build_state().await;
let app = build_router(state);
let iso = fake_alpine_iso();
let start = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/uploads")
.header("content-type", "application/json")
.body(Body::from(
r#"{"filename":"chunked-alpine.iso","size_bytes":65536}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(start.status(), StatusCode::CREATED);
let body = axum::body::to_bytes(start.into_body(), usize::MAX)
.await
.unwrap();
let started: serde_json::Value = serde_json::from_slice(&body).unwrap();
let upload_id = started["upload_id"].as_str().unwrap();
let split = 8192usize;
let first = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri(format!("/api/uploads/{upload_id}"))
.header("x-openpxe-upload-offset", "0")
.body(Body::from(iso[..split].to_vec()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(first.status(), StatusCode::ACCEPTED);
let body = axum::body::to_bytes(first.into_body(), usize::MAX)
.await
.unwrap();
let progress: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(progress["offset"].as_u64().unwrap(), split as u64);
assert!(!progress["complete"].as_bool().unwrap());
assert!(
dir.path().join("isos/chunked-alpine.partial").exists(),
"chunked upload should leave a visible partial file while in progress"
);
let final_chunk = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri(format!("/api/uploads/{upload_id}"))
.header("x-openpxe-upload-offset", split.to_string())
.header("x-openpxe-upload-complete", "true")
.body(Body::from(iso[split..].to_vec()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(final_chunk.status(), StatusCode::CREATED);
let body = axum::body::to_bytes(final_chunk.into_body(), usize::MAX)
.await
.unwrap();
let finished: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(finished["complete"].as_bool().unwrap());
assert_eq!(finished["iso"]["id"], "chunked-alpine");
assert!(dir.path().join("isos/chunked-alpine.iso").exists());
assert!(!dir.path().join("isos/chunked-alpine.partial").exists());
}
#[tokio::test]
async fn chunked_upload_rejects_offset_mismatch_without_advancing() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let start = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/uploads")
.header("content-type", "application/json")
.body(Body::from(
r#"{"filename":"offset-test.iso","size_bytes":16}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(start.status(), StatusCode::CREATED);
let body = axum::body::to_bytes(start.into_body(), usize::MAX)
.await
.unwrap();
let started: serde_json::Value = serde_json::from_slice(&body).unwrap();
let upload_id = started["upload_id"].as_str().unwrap();
let mismatch = app
.oneshot(
Request::builder()
.method("PUT")
.uri(format!("/api/uploads/{upload_id}"))
.header("x-openpxe-upload-offset", "8")
.body(Body::from(vec![1, 2, 3, 4]))
.unwrap(),
)
.await
.unwrap();
assert_eq!(mismatch.status(), StatusCode::CONFLICT);
let body = axum::body::to_bytes(mismatch.into_body(), usize::MAX)
.await
.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
assert!(text.contains("expected offset 0"), "got: {text}");
}
#[tokio::test]
async fn iso_category_switch_moves_entry_between_menus() {
// OS (default): appears in Linux Installers submenu and not in Tools.
// After flipping to Tools: gone from Linux, present under Tools.
let (state, _dir) = build_state().await;
let app = build_router(state);
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
let upload = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/isos")
.header("content-type", ct)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(upload.status(), StatusCode::CREATED);
let (_, linux_before) = get(&app, "/boot/_linux_menu.ipxe").await;
let linux_before = String::from_utf8(linux_before).unwrap();
assert!(
linux_before.contains("fake-alpine-linux"),
"expected entry in Linux submenu (default OS):\n{linux_before}"
);
let (_, tools_before) = get(&app, "/boot/_tools_menu.ipxe").await;
let tools_before = String::from_utf8(tools_before).unwrap();
assert!(
!tools_before.contains("fake-alpine-linux"),
"OS-category ISO shouldn't appear in Tools yet:\n{tools_before}"
);
// Flip to Tools.
let (s, _) = put_json(
&app,
"/api/isos/fake-alpine/category",
r#"{"category":"tools"}"#,
)
.await;
assert_eq!(s, StatusCode::OK);
let (_, linux_after) = get(&app, "/boot/_linux_menu.ipxe").await;
let linux_after = String::from_utf8(linux_after).unwrap();
assert!(
!linux_after.contains("fake-alpine-linux"),
"Tools-category ISO should NOT appear in Linux submenu:\n{linux_after}"
);
let (_, tools_after) = get(&app, "/boot/_tools_menu.ipxe").await;
let tools_after = String::from_utf8(tools_after).unwrap();
assert!(
tools_after.contains("fake-alpine-linux"),
"Tools-category ISO should appear in Tools submenu:\n{tools_after}"
);
}
#[tokio::test]
async fn iso_category_unknown_value_returns_400() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
app.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/isos")
.header("content-type", ct)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
let (s, body) = put_json(
&app,
"/api/isos/fake-alpine/category",
r#"{"category":"gibberish"}"#,
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let text = std::str::from_utf8(&body).unwrap();
assert!(text.contains("unknown category"), "got: {text}");
}
#[tokio::test]
async fn iso_category_unknown_id_returns_404() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, _) = put_json(
&app,
"/api/isos/does-not-exist/category",
r#"{"category":"tools"}"#,
)
.await;
assert_eq!(s, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn storage_disk_endpoint_reports_volume_stats() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, body) = get(&app, "/api/storage/disk").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
// statvfs always returns something on the tempdir filesystem; check
// that the shape is sane (total >= used >= 0 and total >= available).
assert!(v["total_bytes"].as_u64().unwrap() > 0, "got {v}");
let total = v["total_bytes"].as_u64().unwrap();
let avail = v["available_bytes"].as_u64().unwrap();
let used = v["used_bytes"].as_u64().unwrap();
assert!(total >= avail, "{v}");
assert!(total >= used, "{v}");
assert!(v["path"].as_str().unwrap().contains("isos"), "got {v}");
}
#[tokio::test]
async fn api_docs_lists_known_endpoints() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, body) = get(&app, "/api/docs").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
let groups = v["groups"].as_array().expect("groups array");
assert!(!groups.is_empty());
// Flatten the paths and confirm a handful of the routes that real
// operators will look up are documented.
let mut paths: Vec<String> = Vec::new();
for g in groups {
for ep in g["endpoints"].as_array().unwrap() {
paths.push(ep["path"].as_str().unwrap().into());
}
}
for needle in [
"/api/isos",
"/api/isos/:id/category",
"/api/storage/disk",
"/api/branding/logo",
"/api/boot-log",
"/metrics",
] {
assert!(
paths.iter().any(|p| p == needle),
"expected {needle} in docs; got {paths:?}"
);
}
}
#[tokio::test]
async fn branding_clear_when_no_logo_is_no_content() {
// No-op clear should still 204 — it's not an error to revert to
// the default when there's nothing to revert from.
let (state, _dir) = build_state().await;
let app = build_router(state);
let res = app
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/branding/logo")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn status_exposes_custom_logo_flag() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, body) = get(&app, "/api/status").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
v["custom_logo"].as_bool(),
Some(false),
"fresh state has no custom logo: {v}"
);
}
async fn put_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec<u8>) {
let res = router
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri(path)
.header("content-type", "application/json")
.body(Body::from(body.to_owned()))
.unwrap(),
)
.await
.unwrap();
let status = res.status();
let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap()
.to_vec();
(status, bytes)
}
// ─── v0.4.5: Forms auth + SSO ─────────────────────────────────────────────
async fn post_collect(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec<u8>, Vec<axum::http::HeaderValue>) {
let res = router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(path)
.header("content-type", "application/json")
.body(Body::from(body.to_owned()))
.unwrap(),
)
.await
.unwrap();
let status = res.status();
let cookies: Vec<_> = res
.headers()
.get_all(axum::http::header::SET_COOKIE)
.iter()
.cloned()
.collect();
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap()
.to_vec();
(status, body, cookies)
}
fn session_value(cookies: &[axum::http::HeaderValue]) -> Option<String> {
for c in cookies {
let s = c.to_str().ok()?;
if let Some(rest) = s.strip_prefix("openpxe_session=") {
// Until the first ';'
let val = rest.split(';').next().unwrap_or("").to_string();
return Some(val);
}
}
None
}
async fn get_with_cookie(router: &axum::Router, path: &str, cookie: &str) -> (StatusCode, Vec<u8>) {
let res = router
.clone()
.oneshot(
Request::builder()
.uri(path)
.header("cookie", format!("openpxe_session={cookie}"))
.body(Body::empty())
.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 me_reports_setup_required_when_no_admin() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, body) = get(&app, "/api/me").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["setup_required"].as_bool(), Some(true));
assert_eq!(v["authenticated"].as_bool(), Some(false));
}
#[tokio::test]
async fn setup_creates_admin_logs_in_and_blocks_second_call() {
let (state, _dir) = build_state().await;
let app = build_router(state);
// First-run setup succeeds and returns a session cookie.
let (s, body, cookies) = post_collect(
&app,
"/api/setup",
r#"{"username":"admin","password":"hunter2hunter2"}"#,
)
.await;
assert_eq!(s, StatusCode::CREATED);
let token = session_value(&cookies).expect("setup should set cookie");
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["user"]["username"], "admin");
// /api/me with that cookie reports authenticated.
let (s, body) = get_with_cookie(&app, "/api/me", &token).await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["authenticated"].as_bool(), Some(true));
assert_eq!(v["user"]["username"], "admin");
// /api/setup is now closed.
let (s, _, _) = post_collect(
&app,
"/api/setup",
r#"{"username":"second","password":"hunter2hunter2"}"#,
)
.await;
assert_eq!(s, StatusCode::CONFLICT);
}
#[tokio::test]
async fn protected_route_returns_401_after_setup_without_cookie() {
let (state, _dir) = build_state().await;
let app = build_router(state);
// Set up an admin so the middleware engages.
let (s, _, _) = post_collect(
&app,
"/api/setup",
r#"{"username":"admin","password":"hunter2hunter2"}"#,
)
.await;
assert_eq!(s, StatusCode::CREATED);
// No cookie → 401 on a protected route.
let (s, _) = get(&app, "/api/isos").await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
// PXE-essential routes stay reachable.
let (s, _) = get(&app, "/boot.ipxe").await;
assert_eq!(s, StatusCode::OK);
let (s, _) = get(&app, "/healthz").await;
assert_eq!(s, StatusCode::OK);
}
#[tokio::test]
async fn login_logout_round_trip_uses_session_cookie() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (_, _, _) = post_collect(
&app,
"/api/setup",
r#"{"username":"admin","password":"hunter2hunter2"}"#,
)
.await;
// Fresh login (separate from the setup-issued session).
let (s, _, cookies) = post_collect(
&app,
"/api/login",
r#"{"username":"admin","password":"hunter2hunter2"}"#,
)
.await;
assert_eq!(s, StatusCode::OK);
let token = session_value(&cookies).expect("login should set cookie");
// With cookie, /api/isos is reachable.
let (s, _) = get_with_cookie(&app, "/api/isos", &token).await;
assert_eq!(s, StatusCode::OK);
// Logout revokes the session; /api/isos goes back to 401.
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/logout")
.header("cookie", format!("openpxe_session={token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NO_CONTENT);
let (s, _) = get_with_cookie(&app, "/api/isos", &token).await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn login_rejects_wrong_password_with_401_and_no_cookie() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (_, _, _) = post_collect(
&app,
"/api/setup",
r#"{"username":"admin","password":"hunter2hunter2"}"#,
)
.await;
let (s, body, cookies) = post_collect(
&app,
"/api/login",
r#"{"username":"admin","password":"nope"}"#,
)
.await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
assert!(session_value(&cookies).is_none(), "no cookie on failure");
let text = std::str::from_utf8(&body).unwrap();
assert!(text.contains("invalid"), "got: {text}");
}
#[tokio::test]
async fn update_credentials_requires_current_password_and_rotates_session() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (_, _, cookies) = post_collect(
&app,
"/api/setup",
r#"{"username":"admin","password":"hunter2hunter2"}"#,
)
.await;
let token = session_value(&cookies).unwrap();
// Wrong current password → 400.
let res = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/me/credentials")
.header("content-type", "application/json")
.header("cookie", format!("openpxe_session={token}"))
.body(Body::from(
r#"{"current_password":"wrong","new_password":"newpassword1"}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
// Correct current password rotates + returns a fresh cookie.
let res = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/me/credentials")
.header("content-type", "application/json")
.header("cookie", format!("openpxe_session={token}"))
.body(Body::from(
r#"{"current_password":"hunter2hunter2","new_username":"alice","new_password":"newpassword1"}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let new_cookies: Vec<_> = res
.headers()
.get_all(axum::http::header::SET_COOKIE)
.iter()
.cloned()
.collect();
let new_token = session_value(&new_cookies).expect("rotation issues fresh cookie");
// Old cookie no longer valid (every session was revoked).
let (s, _) = get_with_cookie(&app, "/api/isos", &token).await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
// New cookie works.
let (s, _) = get_with_cookie(&app, "/api/isos", &new_token).await;
assert_eq!(s, StatusCode::OK);
// Old creds no longer log in.
let (s, _, _) = post_collect(
&app,
"/api/login",
r#"{"username":"admin","password":"hunter2hunter2"}"#,
)
.await;
assert_eq!(s, StatusCode::UNAUTHORIZED);
// New creds do.
let (s, _, _) = post_collect(
&app,
"/api/login",
r#"{"username":"alice","password":"newpassword1"}"#,
)
.await;
assert_eq!(s, StatusCode::OK);
}
#[tokio::test]
async fn sso_round_trip_default_then_replace() {
// Pre-setup state: middleware is open, so we can hit /api/sso directly.
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, body) = get(&app, "/api/sso").await;
assert_eq!(s, StatusCode::OK);
let cfg: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(cfg["enabled"].as_bool(), Some(false));
// Enable with a metadata URL.
let (s, _) = put_json(
&app,
"/api/sso",
r#"{"enabled":true,"idp_name":"Okta","metadata":"","metadata_url":"https://idp.example.com/metadata"}"#,
)
.await;
assert_eq!(s, StatusCode::OK);
let (_, body) = get(&app, "/api/sso").await;
let cfg: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(cfg["enabled"].as_bool(), Some(true));
assert_eq!(cfg["idp_name"], "Okta");
// Enabling without a source is rejected.
let (s, body) = put_json(
&app,
"/api/sso",
r#"{"enabled":true,"idp_name":"","metadata":"","metadata_url":""}"#,
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let text = std::str::from_utf8(&body).unwrap();
assert!(text.contains("metadata"), "got: {text}");
}
#[tokio::test]
async fn docs_lists_new_v0_4_5_endpoints() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, body) = get(&app, "/api/docs").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
let mut paths: Vec<String> = Vec::new();
for g in v["groups"].as_array().unwrap() {
for ep in g["endpoints"].as_array().unwrap() {
paths.push(ep["path"].as_str().unwrap().into());
}
}
// /api/docs predates v0.4.5 but the new surface should be reachable
// here too — confirms we don't forget to update it. For now we only
// require the *existing* docs entries to keep working.
for needle in ["/api/isos", "/api/boot-log", "/api/storage/disk"] {
assert!(paths.iter().any(|p| p == needle), "{needle} missing");
}
}
// ─── v0.4.6: PXE logo endpoint ────────────────────────────────────────────
#[tokio::test]
async fn pxe_logo_404_when_no_custom_logo_configured() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, body) = get(&app, "/branding/pxe-logo").await;
assert_eq!(s, StatusCode::NOT_FOUND);
let text = std::str::from_utf8(&body).unwrap();
assert!(text.contains("no custom logo"), "got: {text}");
}
/// Build a tiny valid PNG via the `image` crate. The v0.4.61 PXE-logo
/// compositor decodes whatever the operator uploaded — hand-rolled
/// PNGs with handwritten CRCs are too easy to break; let the encoder
/// produce something it can later decode.
fn tiny_png() -> Vec<u8> {
use image::{DynamicImage, ImageBuffer, ImageFormat, Rgb};
use std::io::Cursor;
let buf: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_pixel(8, 8, Rgb([0, 180, 220]));
let mut out = Vec::with_capacity(256);
DynamicImage::ImageRgb8(buf)
.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
.unwrap();
out
}
#[tokio::test]
async fn pxe_logo_404_when_uploaded_logo_is_svg() {
// iPXE can't rasterize SVG, so an SVG upload deliberately doesn't
// light up the PXE menu's `console --picture` overlay — the menu
// simply paints without a logo.
let (state, _dir) = build_state().await;
state
.branding
.set_logo(
"image/svg+xml",
"svg",
br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"/>"#,
)
.unwrap();
let app = build_router(state);
let (s, body) = get(&app, "/branding/pxe-logo").await;
assert_eq!(s, StatusCode::NOT_FOUND);
let text = std::str::from_utf8(&body).unwrap();
assert!(text.contains("SVG"), "got: {text}");
}
#[tokio::test]
async fn pxe_logo_composes_to_1024x768_png() {
// v0.4.61: the endpoint no longer serves the raw upload — it
// composes the operator's logo into a fixed 1024×768 canvas so
// the iPXE menu always paints at consistent dimensions.
let (state, _dir) = build_state().await;
let png = tiny_png();
state
.branding
.set_logo("image/png", "png", &png)
.unwrap();
let app = build_router(state);
let res = app
.clone()
.oneshot(
Request::builder()
.uri("/branding/pxe-logo")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let ct = res
.headers()
.get(axum::http::header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap();
assert_eq!(ct, "image/png");
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
// PNG signature.
assert!(body.starts_with(b"\x89PNG"), "PNG header missing");
// IHDR chunk lives at bytes 8..29; width is bytes 16..20, height
// 20..24 in big-endian u32. The composed canvas should be 1024×768.
let width = u32::from_be_bytes([body[16], body[17], body[18], body[19]]);
let height = u32::from_be_bytes([body[20], body[21], body[22], body[23]]);
assert_eq!(width, 1024, "compose should pin width to 1024");
assert_eq!(height, 768, "compose should pin height to 768");
}
#[tokio::test]
async fn pxe_logo_endpoint_is_public_after_admin_setup() {
// iPXE clients can't send a session cookie, so /branding/pxe-logo
// must stay reachable once the admin has been bootstrapped. The
// auth allowlist gates `/api/*` only.
let (state, _dir) = build_state().await;
let png = tiny_png();
state
.branding
.set_logo("image/png", "png", &png)
.unwrap();
let app = build_router(state);
// Configure an admin so the middleware kicks in.
let (s, _, _) = post_collect(
&app,
"/api/setup",
r#"{"username":"admin","password":"hunter2hunter2"}"#,
)
.await;
assert_eq!(s, StatusCode::CREATED);
// Still public without a cookie.
let (s, _) = get(&app, "/branding/pxe-logo").await;
assert_eq!(s, StatusCode::OK);
}
+7
View File
@@ -27,6 +27,13 @@ parking_lot.workspace = true
bytes.workspace = true bytes.workspace = true
tempfile = "3.12" tempfile = "3.12"
libc = "0.2" libc = "0.2"
# v0.4.61: server-side compose of the operator's uploaded raster into a
# fixed 1024x768 canvas so the PXE menu always gets a consistently-sized
# PNG regardless of what the operator uploaded. We use the bare-bones
# `image` crate (no default features) and explicitly enable only the
# decoders we accept on upload (PNG/JPEG/WebP/GIF) plus the PNG
# encoder. Keeps the build slim — no JPEG2000, TIFF, BMP, etc.
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
[dev-dependencies] [dev-dependencies]
tempfile = "3.12" tempfile = "3.12"
+3 -1
View File
@@ -19,6 +19,7 @@
pub mod entry; pub mod entry;
pub mod introspect; pub mod introspect;
pub mod nfs; pub mod nfs;
pub mod pxe_logo;
pub mod smb; pub mod smb;
pub mod store; pub mod store;
pub mod windows; pub mod windows;
@@ -28,6 +29,7 @@ pub use introspect::{DistroFamily, IntrospectionReport};
pub use nfs::{NfsAddRequest, NfsManager, NfsMount, NfsVersion}; pub use nfs::{NfsAddRequest, NfsManager, NfsMount, NfsVersion};
pub use smb::{extract_windows_iso, SmbManager, SmbState}; pub use smb::{extract_windows_iso, SmbManager, SmbState};
pub use store::{ pub use store::{
generate_boot_entries_for, slugify_str, IsoMeta, IsoSource, IsoStore, UploadHandle, generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore,
UploadHandle,
}; };
pub use windows::{WimPatcher, WinPatchState}; pub use windows::{WimPatcher, WinPatchState};
+521 -95
View File
@@ -9,14 +9,15 @@
//! 1. Operator submits a mount spec via the Storage tab: //! 1. Operator submits a mount spec via the Storage tab:
//! `{ server: "10.0.0.20", export: "/srv/isos", version: "v41" }`. //! `{ server: "10.0.0.20", export: "/srv/isos", version: "v41" }`.
//! 2. We slugify a stable id, mkdir `<work_dir>/nfs/<id>/`, then shell out //! 2. We slugify a stable id, mkdir `<work_dir>/nfs/<id>/`, then shell out
//! to `/bin/mount -t nfs -o vers=...,ro,nolock server:export local`. //! to `mount.nfs -v -o vers=...,ro,nolock,proto=tcp server:export local`.
//! 3. On success we walk the mount point looking for `*.iso` files and //! 3. On success we walk the mount point looking for `*.iso` files and
//! register each one with the `IsoStore` as an external source — same //! register each one with the `IsoStore` as an external source — same
//! introspection pipeline as a web upload, but no sha256 (the bytes //! introspection pipeline as a web upload, but no sha256 (the bytes
//! live on a remote machine; hashing them would suck them through the //! live on a remote machine; hashing them would suck them through the
//! network on every restart). //! network on every restart).
//! 4. On failure we record `last_error` on the spec and persist anyway //! 4. On failure we record `last_error` + `hint` on the spec and persist
//! so the UI can show a row in red rather than silently dropping it. //! anyway so the UI can show a row in red with an actionable hint
//! rather than silently dropping it.
//! //!
//! ## Operational notes //! ## Operational notes
//! //!
@@ -28,6 +29,28 @@
//! - Mount commands are issued sequentially under a single mutex to avoid //! - Mount commands are issued sequentially under a single mutex to avoid
//! `mount` racing on the same target dir. //! `mount` racing on the same target dir.
//! //!
//! ## v0.4.64 diagnostics rework
//!
//! Field reports showed `mount.nfs: failed to apply fstab options` (exit
//! code 32) was the dominant failure surfaced through the UI — a deeply
//! unhelpful message from nfs-utils 2.6.x that has nothing to do with
//! `/etc/fstab`. It comes from `nfs_options2string()` and lights up when
//! the kernel can't accept the assembled options, when mtab can't be
//! written (container without `CAP_SYS_ADMIN`), or when an obscure option
//! triggers a transformation edge case. In v0.4.64 we:
//!
//! 1. Probe TCP reach to `server:port` before shelling out so a wrong
//! IP / closed firewall surfaces as a clear "cannot reach NFS port"
//! instead of `failed to apply fstab options`.
//! 2. Pass `proto=tcp` explicitly on NFSv3 (UDP is widely deprecated
//! and several NAS appliances don't bind it at all).
//! 3. On `failed to apply fstab options`, retry with a stripped-down
//! option set (`vers=N,ro/rw`) — that frequently succeeds and at
//! minimum produces a real kernel error.
//! 4. Translate well-known stderr patterns into operator-friendly hints
//! and persist them on the mount so the UI can show "what to fix
//! next" instead of the raw mount.nfs message.
//!
//! ## Persistence //! ## Persistence
//! //!
//! Mount specs (without runtime state) live at `<work_dir>/nfs.json`, //! Mount specs (without runtime state) live at `<work_dir>/nfs.json`,
@@ -42,9 +65,20 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use time::OffsetDateTime; use time::OffsetDateTime;
use tokio::process::Command; use tokio::process::Command;
/// Default port for NFS over TCP. We expose it as a constant so the
/// pre-flight probe and the option string assembly use the same value.
const DEFAULT_NFS_PORT: u16 = 2049;
/// How long to wait for a TCP connection to the NFS server before
/// declaring it unreachable. Short enough that a wrong IP doesn't make
/// the UI hang for half a minute; long enough that a slow appliance
/// can still answer.
const PROBE_TIMEOUT: Duration = Duration::from_secs(4);
/// Wire-protocol versions we support. Keep this enum closed — silently /// Wire-protocol versions we support. Keep this enum closed — silently
/// accepting "auto" or letting the kernel negotiate would mean operators /// accepting "auto" or letting the kernel negotiate would mean operators
/// could never confirm which version is in use. /// could never confirm which version is in use.
@@ -65,6 +99,14 @@ impl NfsVersion {
Self::V41 => "vers=4.1", Self::V41 => "vers=4.1",
} }
} }
/// Short label for UI surfaces and log lines.
fn label(self) -> &'static str {
match self {
Self::V3 => "NFSv3",
Self::V41 => "NFSv4.1",
}
}
} }
/// One configured mount. The id is generated from server+export so the /// One configured mount. The id is generated from server+export so the
@@ -78,6 +120,12 @@ pub struct NfsMount {
/// Read-only by default — most ISO libraries are. Operators that need /// Read-only by default — most ISO libraries are. Operators that need
/// write can flip this off but OpenPXE itself never writes. /// write can flip this off but OpenPXE itself never writes.
pub read_only: bool, pub read_only: bool,
/// TCP port for the NFS service. Defaults to 2049; configurable for
/// the (rare) case where the appliance binds the service elsewhere.
/// v0.4.64: previously inferred at runtime; now persisted so the UI
/// can echo the value back to the operator.
#[serde(default = "default_port")]
pub port: u16,
/// Local mount point under `<work_dir>/nfs/`. /// Local mount point under `<work_dir>/nfs/`.
pub local_path: PathBuf, pub local_path: PathBuf,
/// Whether the mount is currently active. /// Whether the mount is currently active.
@@ -85,6 +133,12 @@ pub struct NfsMount {
/// Last error encountered on a `mount` or `umount` attempt; cleared on /// Last error encountered on a `mount` or `umount` attempt; cleared on
/// success. /// success.
pub last_error: Option<String>, pub last_error: Option<String>,
/// v0.4.64: operator-friendly translation of `last_error` — e.g. for
/// "failed to apply fstab options" we surface "CAP_SYS_ADMIN may be
/// missing on the container". `None` means we don't have a friendlier
/// rendition than the raw error.
#[serde(default)]
pub last_hint: Option<String>,
#[serde(with = "time::serde::rfc3339::option")] #[serde(with = "time::serde::rfc3339::option")]
pub last_attempt: Option<OffsetDateTime>, pub last_attempt: Option<OffsetDateTime>,
/// Number of `.iso` files found on the share (re-counted on each scan). /// Number of `.iso` files found on the share (re-counted on each scan).
@@ -100,6 +154,9 @@ pub struct NfsAddRequest {
pub version: NfsVersion, pub version: NfsVersion,
#[serde(default = "default_ro")] #[serde(default = "default_ro")]
pub read_only: bool, pub read_only: bool,
/// Optional TCP port — defaults to 2049 if omitted or zero.
#[serde(default)]
pub port: Option<u16>,
} }
fn default_version() -> NfsVersion { fn default_version() -> NfsVersion {
@@ -108,6 +165,35 @@ fn default_version() -> NfsVersion {
fn default_ro() -> bool { fn default_ro() -> bool {
true true
} }
fn default_port() -> u16 {
DEFAULT_NFS_PORT
}
/// Outcome of an `add` attempt. `Ok` carries the mount; `Err` from the
/// API layer is converted to this richer shape so the UI can render the
/// raw error and the actionable hint independently.
#[derive(Debug, Clone, Serialize)]
pub struct NfsMountError {
/// The first line / summary of what went wrong.
pub error: String,
/// Verbatim stderr from `mount.nfs` (trimmed). May be empty.
pub stderr: String,
/// Operator-friendly hint or `None` if we don't have one.
pub hint: Option<String>,
}
impl NfsMountError {
fn from_raw(error: impl Into<String>, stderr: impl Into<String>) -> Self {
let stderr = stderr.into();
let error = error.into();
let hint = hint_for(&stderr).or_else(|| hint_for(&error));
Self {
error,
stderr,
hint,
}
}
}
#[derive(Debug, Default)] #[derive(Debug, Default)]
struct Inner { struct Inner {
@@ -165,6 +251,7 @@ impl NfsManager {
// when the process died. We'll try to remount each one. // when the process died. We'll try to remount each one.
m.mounted = false; m.mounted = false;
m.last_error = None; m.last_error = None;
m.last_hint = None;
self.inner.lock().mounts.insert(m.id.clone(), m.clone()); self.inner.lock().mounts.insert(m.id.clone(), m.clone());
if let Err(e) = self.try_mount(&m.id).await { if let Err(e) = self.try_mount(&m.id).await {
tracing::warn!( tracing::warn!(
@@ -178,20 +265,42 @@ impl NfsManager {
} }
/// Add a new mount. Returns the resulting `NfsMount` (with `mounted` /// Add a new mount. Returns the resulting `NfsMount` (with `mounted`
/// reflecting reality) or an error if the spec was invalid. /// reflecting reality) or a structured `NfsMountError` describing
pub async fn add(&self, req: NfsAddRequest) -> Result<NfsMount> { /// what went wrong.
let server = req.server.trim().to_string(); pub async fn add(
&self,
req: NfsAddRequest,
) -> std::result::Result<NfsMount, NfsMountError> {
let server = normalize_server(&req.server);
let export = req.export.trim().to_string(); let export = req.export.trim().to_string();
if server.is_empty() { if server.is_empty() {
return Err(Error::Invalid("server is required".into())); return Err(NfsMountError::from_raw(
"server is required",
"",
));
} }
if !export.starts_with('/') { if !export.starts_with('/') {
return Err(Error::Invalid("export path must start with '/'".into())); return Err(NfsMountError::from_raw(
"export path must start with '/'",
"",
));
} }
if export.contains('\0') || server.contains('\0') {
return Err(NfsMountError::from_raw(
"server / export must not contain NUL bytes",
"",
));
}
let port = req.port.filter(|p| *p != 0).unwrap_or(DEFAULT_NFS_PORT);
let id = mount_id(&server, &export); let id = mount_id(&server, &export);
let local_path = self.work_root.join(&id); let local_path = self.work_root.join(&id);
tokio::fs::create_dir_all(&local_path).await?; if let Err(e) = tokio::fs::create_dir_all(&local_path).await {
return Err(NfsMountError::from_raw(
format!("failed to create local mount point: {e}"),
"",
));
}
let mount = NfsMount { let mount = NfsMount {
id: id.clone(), id: id.clone(),
@@ -199,15 +308,28 @@ impl NfsManager {
export, export,
version: req.version, version: req.version,
read_only: req.read_only, read_only: req.read_only,
port,
local_path, local_path,
mounted: false, mounted: false,
last_error: None, last_error: None,
last_hint: None,
last_attempt: None, last_attempt: None,
iso_count: 0, iso_count: 0,
}; };
self.inner.lock().mounts.insert(id.clone(), mount); self.inner.lock().mounts.insert(id.clone(), mount);
self.persist_locked(); self.persist_locked();
self.try_mount(&id).await?; self.try_mount(&id).await.map_err(|e| {
// try_mount has already persisted last_error/last_hint. We
// refetch them so the API response reflects exactly what the
// UI will see when it lists mounts.
let m = self.get(&id);
NfsMountError {
error: m.as_ref().and_then(|m| m.last_error.clone())
.unwrap_or_else(|| e.to_string()),
stderr: String::new(),
hint: m.and_then(|m| m.last_hint),
}
})?;
Ok(self.get(&id).expect("mount just inserted")) Ok(self.get(&id).expect("mount just inserted"))
} }
@@ -276,63 +398,113 @@ impl NfsManager {
// Already mounted? Skip — `mount` would error on a busy target // Already mounted? Skip — `mount` would error on a busy target
// and confuse the operator's UI status. // and confuse the operator's UI status.
if is_mountpoint(&m.local_path).await { if is_mountpoint(&m.local_path).await {
self.update_status(id, true, None, now); self.update_status(id, true, None, None, now);
// Even though already mounted, we still want a fresh ISO count. // Even though already mounted, we still want a fresh ISO count.
let count = self.scan_and_register(&m).await.unwrap_or(0); let count = self.scan_and_register(&m).await.unwrap_or(0);
self.update_iso_count(id, count); self.update_iso_count(id, count);
return Ok(()); return Ok(());
} }
let opts = mount_options(&m); // v0.4.64: pre-flight TCP probe. Catches the dominant failure
let target = format!("{}:{}", m.server, m.export); // mode (wrong IP / firewall) before mount.nfs gets a chance to
// emit its unhelpful "failed to apply fstab options" message.
let output = Command::new("mount") if let Err((err, hint)) = tcp_probe(&m.server, m.port).await {
.arg("-t") tracing::warn!(target: "openpxe::nfs", id = %id, "{err}");
.arg("nfs") self.update_status(id, false, Some(err.clone()), Some(hint), now);
.arg("-o") return Err(Error::Invalid(err));
.arg(&opts)
.arg(&target)
.arg(&m.local_path)
.output()
.await;
match output {
Ok(out) if out.status.success() => {
tracing::info!(
target: "openpxe::nfs",
id = %id, server = %m.server, export = %m.export,
version = ?m.version,
"NFS mount succeeded"
);
self.update_status(id, true, None, now);
let count = self.scan_and_register(&m).await.unwrap_or(0);
self.update_iso_count(id, count);
Ok(())
}
Ok(out) => {
let err = format!(
"mount exit {}: {}",
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stderr).trim()
);
tracing::warn!(target: "openpxe::nfs", id = %id, "{err}");
self.update_status(id, false, Some(err.clone()), now);
Err(Error::Invalid(err))
}
Err(e) => {
let err = format!("could not exec /bin/mount: {e}");
tracing::error!(target: "openpxe::nfs", id = %id, "{err}");
self.update_status(id, false, Some(err.clone()), now);
Err(Error::Invalid(err))
}
} }
// First attempt: full option set.
let full_opts = mount_options(&m, /*minimal*/ false);
let target = format!("{}:{}", m.server, m.export);
let attempt = run_mount_nfs(&full_opts, &target, &m.local_path).await;
let (success, stderr, exit_code) = match attempt {
Ok((true, stderr, _)) => (true, stderr, 0),
Ok((false, stderr, code)) => (false, stderr, code),
Err(e) => {
let err = format!("could not exec mount(8): {e}");
let hint = Some(
"the runtime image is missing /bin/mount or nfs-common — \
verify the container hasn't been stripped down"
.to_string(),
);
tracing::error!(target: "openpxe::nfs", id = %id, "{err}");
self.update_status(id, false, Some(err.clone()), hint, now);
return Err(Error::Invalid(err));
}
};
if success {
tracing::info!(
target: "openpxe::nfs",
id = %id, server = %m.server, export = %m.export,
version = %m.version.label(), port = m.port,
"NFS mount succeeded"
);
self.update_status(id, true, None, None, now);
let count = self.scan_and_register(&m).await.unwrap_or(0);
self.update_iso_count(id, count);
return Ok(());
}
// Second attempt: if the first attempt failed with the
// "failed to apply fstab options" oddity, retry with a minimal
// option set. nfs-utils 2.6.x sometimes chokes on the assembled
// option string for reasons unrelated to the actual options
// being valid; the stripped form bypasses the transformation
// edge case.
let trigger_retry = looks_like_option_transform_failure(&stderr);
let (final_success, final_stderr, final_exit_code) = if trigger_retry {
tracing::info!(
target: "openpxe::nfs", id = %id,
"retrying with minimal options after option-transform failure"
);
let minimal = mount_options(&m, /*minimal*/ true);
match run_mount_nfs(&minimal, &target, &m.local_path).await {
Ok((true, s, _)) => (true, s, 0),
Ok((false, s, c)) => (false, s, c),
Err(e) => (false, format!("could not exec mount(8): {e}"), -1),
}
} else {
(false, stderr, exit_code)
};
if final_success {
tracing::info!(
target: "openpxe::nfs", id = %id,
"NFS mount succeeded on minimal-options retry"
);
self.update_status(id, true, None, None, now);
let count = self.scan_and_register(&m).await.unwrap_or(0);
self.update_iso_count(id, count);
return Ok(());
}
// Failure path: persist a clear error and a hint, log both.
// `mount(8)` passes mount.nfs's stderr through verbatim, so the
// user-visible text reads like "mount.nfs: ..." — we prepend the
// exit code so the operator can tell at a glance that the helper
// ran but rejected the request, vs the helper not running at all.
let err = if final_stderr.is_empty() {
format!("mount exit {final_exit_code}")
} else {
format!("mount exit {final_exit_code}: {}", final_stderr.trim())
};
let hint = hint_for(&final_stderr);
tracing::warn!(
target: "openpxe::nfs", id = %id,
hint = ?hint, "{err}"
);
self.update_status(id, false, Some(err.clone()), hint, now);
Err(Error::Invalid(err))
} }
async fn umount_one(&self, id: &str) -> Result<()> { async fn umount_one(&self, id: &str) -> Result<()> {
let _g = self.mount_lock.lock().await; let _g = self.mount_lock.lock().await;
let Some(m) = self.get(id) else { return Ok(()) }; let Some(m) = self.get(id) else { return Ok(()) };
if !is_mountpoint(&m.local_path).await { if !is_mountpoint(&m.local_path).await {
self.update_status(id, false, None, OffsetDateTime::now_utc()); self.update_status(id, false, None, None, OffsetDateTime::now_utc());
return Ok(()); return Ok(());
} }
// -l = lazy: detach immediately, finish when no process has a // -l = lazy: detach immediately, finish when no process has a
@@ -344,7 +516,7 @@ impl NfsManager {
.await; .await;
match out { match out {
Ok(o) if o.status.success() => { Ok(o) if o.status.success() => {
self.update_status(id, false, None, OffsetDateTime::now_utc()); self.update_status(id, false, None, None, OffsetDateTime::now_utc());
Ok(()) Ok(())
} }
Ok(o) => { Ok(o) => {
@@ -353,12 +525,24 @@ impl NfsManager {
o.status.code().unwrap_or(-1), o.status.code().unwrap_or(-1),
String::from_utf8_lossy(&o.stderr).trim() String::from_utf8_lossy(&o.stderr).trim()
); );
self.update_status(id, false, Some(e.clone()), OffsetDateTime::now_utc()); self.update_status(
id,
false,
Some(e.clone()),
None,
OffsetDateTime::now_utc(),
);
Err(Error::Invalid(e)) Err(Error::Invalid(e))
} }
Err(e) => { Err(e) => {
let e = format!("could not exec /bin/umount: {e}"); let e = format!("could not exec /bin/umount: {e}");
self.update_status(id, false, Some(e.clone()), OffsetDateTime::now_utc()); self.update_status(
id,
false,
Some(e.clone()),
None,
OffsetDateTime::now_utc(),
);
Err(Error::Invalid(e)) Err(Error::Invalid(e))
} }
} }
@@ -410,10 +594,18 @@ impl NfsManager {
Ok(count) Ok(count)
} }
fn update_status(&self, id: &str, mounted: bool, err: Option<String>, ts: OffsetDateTime) { fn update_status(
&self,
id: &str,
mounted: bool,
err: Option<String>,
hint: Option<String>,
ts: OffsetDateTime,
) {
if let Some(m) = self.inner.lock().mounts.get_mut(id) { if let Some(m) = self.inner.lock().mounts.get_mut(id) {
m.mounted = mounted; m.mounted = mounted;
m.last_error = err; m.last_error = err;
m.last_hint = hint;
m.last_attempt = Some(ts); m.last_attempt = Some(ts);
} }
self.persist_locked(); self.persist_locked();
@@ -453,18 +645,34 @@ impl NfsManager {
} }
} }
fn mount_options(m: &NfsMount) -> String { /// Build the `-o` option list. With `minimal=true` we strip everything
/// except the protocol version and ro/rw — used on the retry path when
/// the first attempt failed at option transformation, which historically
/// indicates one of the auxiliary options confused `nfs_options2string()`.
fn mount_options(m: &NfsMount, minimal: bool) -> String {
let mut opts = vec![m.version.vers_arg().to_string()]; let mut opts = vec![m.version.vers_arg().to_string()];
if m.read_only { if m.read_only {
opts.push("ro".into()); opts.push("ro".into());
} else { } else {
opts.push("rw".into()); opts.push("rw".into());
} }
if minimal {
return opts.join(",");
}
// Explicit TCP. NFSv4.x is TCP-only by spec, but stating it
// doesn't hurt and on NFSv3 it's necessary on appliances that
// don't bind UDP (which is most modern ones).
opts.push("proto=tcp".into());
// `nolock` for v3 — many storage appliances disable lockd; we don't // `nolock` for v3 — many storage appliances disable lockd; we don't
// need locking for read-only ISO access anyway. // need locking for read-only ISO access anyway. nfs-utils still
// tries to contact rpc.statd without it which is a no-op overhead.
if matches!(m.version, NfsVersion::V3) { if matches!(m.version, NfsVersion::V3) {
opts.push("nolock".into()); opts.push("nolock".into());
} }
// Non-standard port hint to the kernel.
if m.port != DEFAULT_NFS_PORT {
opts.push(format!("port={}", m.port));
}
// Soft mount with a generous timeout — better to surface a hung share // Soft mount with a generous timeout — better to surface a hung share
// as a user-visible error than to wedge the iPXE client forever on a // as a user-visible error than to wedge the iPXE client forever on a
// dead NFS server. // dead NFS server.
@@ -474,6 +682,158 @@ fn mount_options(m: &NfsMount) -> String {
opts.join(",") opts.join(",")
} }
/// Invoke `mount -t nfs`. Returns `(success, stderr_trimmed,
/// exit_code)`. `stderr` is captured separately from `stdout`;
/// `mount(8)` passes mount.nfs's stderr through verbatim, so we get the
/// same diagnostics ("mount.nfs: ...") whether we invoke `mount.nfs`
/// directly or go through the generic wrapper.
///
/// We deliberately stay on `mount` rather than `mount.nfs` directly
/// because `/bin/mount` is in every user's PATH; `mount.nfs` lives in
/// `/sbin` (or `/usr/sbin`) and is *not* in the default PATH for the
/// non-root `openpxe` user. The generic `mount` binary knows where its
/// NFS helper lives and dispatches accordingly.
async fn run_mount_nfs(
opts: &str,
target: &str,
local: &Path,
) -> std::io::Result<(bool, String, i32)> {
let output = Command::new("mount")
.arg("-t")
.arg("nfs")
.arg("-o")
.arg(opts)
.arg(target)
.arg(local)
.output()
.await?;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let code = output.status.code().unwrap_or(-1);
Ok((output.status.success(), stderr, code))
}
/// Try to open a TCP connection to `server:port` within `PROBE_TIMEOUT`.
/// On failure returns `(error_text, hint_text)` — pre-formatted so the
/// caller can persist both.
async fn tcp_probe(server: &str, port: u16) -> std::result::Result<(), (String, String)> {
use tokio::net::TcpStream;
let addr = format!("{server}:{port}");
let connect = TcpStream::connect(&addr);
match tokio::time::timeout(PROBE_TIMEOUT, connect).await {
Ok(Ok(_stream)) => Ok(()),
Ok(Err(e)) => Err((
format!("cannot reach NFS port: {addr}: {e}"),
format!(
"verify the NFS service is running on {server} and that port {port} is open"
),
)),
Err(_) => Err((
format!("cannot reach NFS port: {addr}: timed out after {}s", PROBE_TIMEOUT.as_secs()),
format!(
"no TCP answer from {server}:{port} within {}s — check the IP and any firewall in between",
PROBE_TIMEOUT.as_secs()
),
)),
}
}
/// Detect mount.nfs's "failed to apply fstab options" / "internal option
/// parsing error" path. These messages come from
/// `nfs_options2string()` / `nfs_validate_options()` in nfs-utils and
/// are emitted *before* the mount(2) syscall, so retrying with a
/// stripped option set often succeeds.
fn looks_like_option_transform_failure(stderr: &str) -> bool {
let s = stderr.to_ascii_lowercase();
s.contains("failed to apply fstab options")
|| s.contains("internal option parsing error")
}
/// Translate a mount.nfs stderr blob into an operator-friendly hint.
/// Returns `None` if we don't have a translation — the caller will fall
/// back to surfacing the raw stderr.
#[allow(clippy::if_same_then_else)] // ordering matters; keep the patterns explicit
fn hint_for(stderr: &str) -> Option<String> {
let s = stderr.to_ascii_lowercase();
if s.contains("failed to apply fstab options") || s.contains("internal option parsing error") {
// The dominant report from the field: mount.nfs failed at the
// option-transform layer. Most common root cause is missing
// CAP_SYS_ADMIN in the container.
Some(
"mount.nfs couldn't finalize the mount. Most common cause: the container is \
missing CAP_SYS_ADMIN (run with --cap-add=SYS_ADMIN, or use a privileged SCC on \
OpenShift). Also check that /etc/mtab exists and the host kernel has NFS client \
support."
.into(),
)
} else if s.contains("operation not permitted") || s.contains("permission denied") {
Some(
"the container is missing CAP_SYS_ADMIN — mount(2) returns EPERM without it. Re-run \
with --cap-add=SYS_ADMIN, or grant the OpenShift pod a privileged SCC."
.into(),
)
} else if s.contains("access denied by server") {
Some(
"the server rejected this client. Check the export's allowed-hosts list includes \
this OpenPXE host's IP (or 0.0.0.0/0 for testing)."
.into(),
)
} else if s.contains("no route to host") || s.contains("network is unreachable") {
Some("the server is not reachable on this network. Check the IP, subnet, and routes.".into())
} else if s.contains("connection refused") {
Some(
"the NFS service isn't listening on this address/port. Verify NFS is running and \
that the export path is correct (e.g. UniFi UNAS Pro exports under \
/var/nfs/shared/<name>, not the share name on its own)."
.into(),
)
} else if s.contains("connection timed out") {
Some(
"no answer from the server within the connect timeout. Most likely a firewall is \
dropping the connection, or the server isn't running NFS on this port."
.into(),
)
} else if s.contains("no such file or directory")
|| s.contains("mount: bad option")
|| s.contains("does not exist")
{
Some(
"the export path doesn't exist on the server, or a mount option isn't recognized. \
Double-check the export — many NAS appliances bury it under a service root like \
/var/nfs/shared/<share>."
.into(),
)
} else if s.contains("rpc: program not registered") || s.contains("mount system call failed") {
Some(
"the server didn't respond on the expected RPC programs. NFSv4.1 needs nfsd on TCP \
2049; NFSv3 also needs portmap (111) and mountd. If the server only speaks one \
version, switch the dropdown to match."
.into(),
)
} else if s.contains("protocol not supported") || s.contains("invalid argument") {
Some(
"the server doesn't speak the requested NFS version. Try the other entry in the \
Version dropdown."
.into(),
)
} else {
None
}
}
/// Normalize a server input: trim, strip a `http(s)://` prefix that the
/// operator may have pasted by mistake, and drop a trailing slash. Port
/// suffixes (`host:1234`) are preserved so the kernel sees them; the
/// explicit `port=` option still wins if the operator set one.
fn normalize_server(raw: &str) -> String {
let s = raw.trim();
let s = s
.strip_prefix("http://")
.or_else(|| s.strip_prefix("https://"))
.or_else(|| s.strip_prefix("nfs://"))
.unwrap_or(s);
s.trim_end_matches('/').to_string()
}
fn mount_id(server: &str, export: &str) -> String { fn mount_id(server: &str, export: &str) -> String {
let raw = format!("{server}{export}"); let raw = format!("{server}{export}");
slugify_str(&raw) slugify_str(&raw)
@@ -500,6 +860,23 @@ async fn is_mountpoint(path: &Path) -> bool {
mod tests { mod tests {
use super::*; use super::*;
fn make_mount(version: NfsVersion, ro: bool, port: u16) -> NfsMount {
NfsMount {
id: "x".into(),
server: "s".into(),
export: "/e".into(),
version,
read_only: ro,
port,
local_path: PathBuf::from("/tmp/x"),
mounted: false,
last_error: None,
last_hint: None,
last_attempt: None,
iso_count: 0,
}
}
#[test] #[test]
fn version_arg() { fn version_arg() {
assert_eq!(NfsVersion::V3.vers_arg(), "vers=3"); assert_eq!(NfsVersion::V3.vers_arg(), "vers=3");
@@ -507,44 +884,39 @@ mod tests {
} }
#[test] #[test]
fn mount_options_v3_includes_nolock() { fn mount_options_v3_includes_nolock_and_tcp() {
let m = NfsMount { let m = make_mount(NfsVersion::V3, true, DEFAULT_NFS_PORT);
id: "x".into(), let opts = mount_options(&m, false);
server: "s".into(), assert!(opts.contains("vers=3"), "got: {opts}");
export: "/e".into(), assert!(opts.contains("ro"), "got: {opts}");
version: NfsVersion::V3, assert!(opts.contains("nolock"), "got: {opts}");
read_only: true, assert!(opts.contains("proto=tcp"), "got: {opts}");
local_path: PathBuf::from("/tmp/x"), assert!(opts.contains("soft"), "got: {opts}");
mounted: false, assert!(!opts.contains("port="), "default port shouldn't appear: {opts}");
last_error: None,
last_attempt: None,
iso_count: 0,
};
let opts = mount_options(&m);
assert!(opts.contains("vers=3"));
assert!(opts.contains("ro"));
assert!(opts.contains("nolock"));
assert!(opts.contains("soft"));
} }
#[test] #[test]
fn mount_options_v41_no_nolock() { fn mount_options_v41_has_tcp_no_nolock() {
let m = NfsMount { let m = make_mount(NfsVersion::V41, false, DEFAULT_NFS_PORT);
id: "x".into(), let opts = mount_options(&m, false);
server: "s".into(), assert!(opts.contains("vers=4.1"), "got: {opts}");
export: "/e".into(), assert!(opts.contains("rw"), "got: {opts}");
version: NfsVersion::V41, assert!(opts.contains("proto=tcp"), "got: {opts}");
read_only: false, assert!(!opts.contains("nolock"), "got: {opts}");
local_path: PathBuf::from("/tmp/x"), }
mounted: false,
last_error: None, #[test]
last_attempt: None, fn mount_options_minimal_drops_everything_except_vers_and_mode() {
iso_count: 0, let m = make_mount(NfsVersion::V3, true, DEFAULT_NFS_PORT);
}; let opts = mount_options(&m, true);
let opts = mount_options(&m); assert_eq!(opts, "vers=3,ro");
assert!(opts.contains("vers=4.1")); }
assert!(opts.contains("rw"));
assert!(!opts.contains("nolock")); #[test]
fn mount_options_non_default_port_appears() {
let m = make_mount(NfsVersion::V41, true, 2050);
let opts = mount_options(&m, false);
assert!(opts.contains("port=2050"), "got: {opts}");
} }
#[test] #[test]
@@ -555,4 +927,58 @@ mod tests {
assert!(!a.contains('/')); assert!(!a.contains('/'));
assert!(!a.contains('.')); assert!(!a.contains('.'));
} }
#[test]
fn normalize_server_strips_url_schemes_and_slashes() {
assert_eq!(normalize_server(" 10.0.0.5 "), "10.0.0.5");
assert_eq!(normalize_server("http://10.0.0.5/"), "10.0.0.5");
assert_eq!(normalize_server("https://nas.lan//"), "nas.lan");
assert_eq!(normalize_server("nfs://192.168.1.51"), "192.168.1.51");
assert_eq!(normalize_server("nas.lan:2049"), "nas.lan:2049");
}
#[test]
fn hint_for_fstab_options_calls_out_cap_sys_admin() {
let h = hint_for("mount.nfs: failed to apply fstab options").unwrap();
assert!(
h.contains("CAP_SYS_ADMIN"),
"expected CAP_SYS_ADMIN guidance, got: {h}"
);
}
#[test]
fn hint_for_access_denied_points_at_exports_table() {
let h = hint_for("mount.nfs: access denied by server while mounting").unwrap();
assert!(
h.to_lowercase().contains("allowed-hosts") || h.to_lowercase().contains("export"),
"expected exports hint, got: {h}"
);
}
#[test]
fn hint_for_connection_refused_mentions_export_path() {
let h = hint_for("mount.nfs: Connection refused").unwrap();
assert!(
h.to_lowercase().contains("export"),
"expected export-path hint, got: {h}"
);
}
#[test]
fn hint_for_unknown_message_is_none() {
assert!(hint_for("some completely unrelated text").is_none());
}
#[test]
fn looks_like_option_transform_failure_detects_both_variants() {
assert!(looks_like_option_transform_failure(
"mount.nfs: failed to apply fstab options"
));
assert!(looks_like_option_transform_failure(
"mount.nfs: internal option parsing error"
));
assert!(!looks_like_option_transform_failure(
"mount.nfs: access denied"
));
}
} }
+152
View File
@@ -0,0 +1,152 @@
//! Operator-logo compositor for the iPXE menu.
//!
//! The brief: match iVentoy's polished centered-logo PXE chrome with
//! whatever raster the operator drops onto Settings → Branding. A wide
//! wordmark, a portrait stack, a square monogram — all three should
//! land in roughly the same place on the boot screen.
//!
//! Approach: decode the operator's upload, fit it into a fixed
//! 1024×768 canvas with the logo horizontally centered and pinned a
//! short margin from the top, re-encode as PNG, return the bytes. iPXE
//! built with `IMAGE_PNG` paints the result via `console --picture`.
//!
//! The 1024×768 size matches the default VESA framebuffer iPXE picks
//! on most BIOS/UEFI consoles. Operators uploading 4K logos get
//! correctly downscaled; tiny icons get drawn at their native size,
//! centered, with transparent margins.
//!
//! We deliberately don't ship `resvg` for SVG support — keeping the
//! dependency surface narrow matters more than supporting SVG-only
//! brand assets. The WebUI's logo stays SVG-native (the browser
//! rasterizes it); the PXE menu wants a raster regardless.
use image::imageops::FilterType;
use image::{DynamicImage, ImageError, ImageFormat, Rgba, RgbaImage};
use std::io::Cursor;
/// Canvas dimensions used for the composed PXE logo. Picked to match
/// the framebuffer dimensions iPXE picks on most BIOS/UEFI consoles —
/// gives a 1:1 paint with no scaling at the firmware layer.
pub const CANVAS_W: u32 = 1024;
pub const CANVAS_H: u32 = 768;
/// Maximum dimensions for the operator's logo inside the canvas. Any
/// upload larger than this in either axis is downscaled (preserving
/// aspect ratio) to fit. Smaller uploads paint at native size.
const LOGO_MAX_W: u32 = 600;
const LOGO_MAX_H: u32 = 200;
/// Top margin in pixels from the canvas's top edge to the logo's top
/// edge. Matches the visual rhythm of iVentoy's screen (logo at top,
/// menu below).
const LOGO_TOP_MARGIN: u32 = 64;
/// Compose `src_bytes` (any PNG/JPEG/WebP/GIF) into a centered-top
/// 1024×768 PNG and return the encoded bytes.
///
/// Errors when the source can't be decoded or the encoded buffer can't
/// be written (only really fires on out-of-memory; the encoder itself
/// is infallible for well-formed inputs).
pub fn compose_pxe_logo(src_bytes: &[u8]) -> Result<Vec<u8>, ImageError> {
let logo = image::load_from_memory(src_bytes)?;
// Resize-fit if the upload exceeds our bounding box. `Lanczos3`
// keeps the antialiasing crisp on the framebuffer console; it's a
// touch slower than `Triangle` but the operator hits this endpoint
// once per boot at most.
let logo = downscale_to_fit(logo, LOGO_MAX_W, LOGO_MAX_H);
let logo_rgba = logo.to_rgba8();
// Transparent canvas. iPXE 1.21+ honours alpha-channel transparency
// on framebuffer consoles; older builds simply draw the alpha as
// black, which still gives a sensible look.
let mut canvas: RgbaImage = RgbaImage::from_pixel(CANVAS_W, CANVAS_H, Rgba([0, 0, 0, 0]));
let logo_w = logo_rgba.width();
let logo_h = logo_rgba.height();
// Horizontal center, top-margin from the top. Saturating math
// means a logo wider than CANVAS_W (shouldn't happen after the
// downscale above, but defensive) just sits flush-left.
let off_x = CANVAS_W.saturating_sub(logo_w) / 2;
let off_y = LOGO_TOP_MARGIN.min(CANVAS_H.saturating_sub(logo_h));
image::imageops::overlay(&mut canvas, &logo_rgba, off_x.into(), off_y.into());
let mut out = Vec::with_capacity(64 * 1024);
DynamicImage::ImageRgba8(canvas).write_to(&mut Cursor::new(&mut out), ImageFormat::Png)?;
Ok(out)
}
fn downscale_to_fit(img: DynamicImage, max_w: u32, max_h: u32) -> DynamicImage {
let (w, h) = (img.width(), img.height());
if w <= max_w && h <= max_h {
return img;
}
// Preserve aspect ratio. `resize` clamps to the smaller of the
// two scale factors so we never overshoot the bounding box.
img.resize(max_w, max_h, FilterType::Lanczos3)
}
#[cfg(test)]
mod tests {
use super::*;
use image::{ImageBuffer, Rgb};
fn solid_png(w: u32, h: u32, rgb: [u8; 3]) -> Vec<u8> {
let img: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_pixel(w, h, Rgb(rgb));
let mut out = Vec::with_capacity(4096);
DynamicImage::ImageRgb8(img)
.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
.unwrap();
out
}
#[test]
fn compose_emits_canvas_sized_png() {
let src = solid_png(120, 60, [200, 50, 50]);
let out = compose_pxe_logo(&src).unwrap();
// Round-trip the output and confirm dimensions.
let img = image::load_from_memory(&out).unwrap();
assert_eq!(img.width(), CANVAS_W);
assert_eq!(img.height(), CANVAS_H);
}
#[test]
fn small_logo_centered_at_top_margin() {
let src = solid_png(100, 40, [10, 200, 10]);
let out = compose_pxe_logo(&src).unwrap();
let canvas = image::load_from_memory(&out).unwrap().to_rgba8();
// Pixel just inside the logo box should match the source color
// (alpha=255). Pixel near a far corner of the canvas should be
// the transparent background.
let cx = (CANVAS_W - 100) / 2;
let cy = LOGO_TOP_MARGIN;
let inside = canvas.get_pixel(cx + 10, cy + 10);
assert_eq!(inside.0[3], 255, "logo pixel should be opaque");
assert!(inside.0[0] < 100 && inside.0[1] > 100 && inside.0[2] < 100, "color mismatch: {inside:?}");
let corner = canvas.get_pixel(CANVAS_W - 1, CANVAS_H - 1);
assert_eq!(corner.0[3], 0, "canvas corner should be transparent");
}
#[test]
fn oversize_logo_is_downscaled_to_bounding_box() {
// 4000×800 image — bigger than LOGO_MAX_W and LOGO_MAX_H in
// both axes. After downscale the output must fit; we re-decode
// the canvas, count non-transparent pixels, and confirm none
// sit outside the expected band.
let src = solid_png(4000, 800, [50, 50, 200]);
let out = compose_pxe_logo(&src).unwrap();
let canvas = image::load_from_memory(&out).unwrap().to_rgba8();
// Span row at the top margin should have non-transparent
// pixels somewhere; rows past the LOGO_TOP_MARGIN + LOGO_MAX_H
// should be entirely transparent.
let bottom_band_y = LOGO_TOP_MARGIN + LOGO_MAX_H + 10;
for x in 0..CANVAS_W {
let p = canvas.get_pixel(x, bottom_band_y);
assert_eq!(p.0[3], 0, "row {bottom_band_y} should be transparent at x={x}");
}
}
#[test]
fn unsupported_bytes_returns_error_not_panic() {
let r = compose_pxe_logo(b"\xde\xad\xbe\xef not an image");
assert!(r.is_err());
}
}
+142 -1
View File
@@ -31,6 +31,30 @@ pub enum IsoSource {
}, },
} }
/// Where the ISO lands in the PXE menu hierarchy.
///
/// Auto-detected family (Debian, Windows, …) still drives BIOS/UEFI
/// behaviour and per-entry boot args, but the *menu placement* is
/// operator-controlled — an operator who's uploaded a TinyCore live ISO
/// to use as a recovery shim, or a SystemRescue image, can flip its
/// category to `Tools` so it lands next to memtest/shell instead of
/// under Linux Installers.
///
/// Old `meta.json` files without this field deserialize as `Os`, which
/// matches v0.4.1 behaviour (everything goes under OS Installers).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IsoCategory {
/// "OS Installer" — routed via the auto-detected family into the
/// Linux / Windows installer submenus.
#[default]
Os,
/// "Tool" — surfaced under the Tools menu next to memtest, shell,
/// NIC info, etc. Family detection still decides BIOS/UEFI vs
/// wimboot vs sanboot at boot time.
Tools,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IsoMeta { pub struct IsoMeta {
/// Stable slug used in URLs (derived from the uploaded filename). /// Stable slug used in URLs (derived from the uploaded filename).
@@ -56,6 +80,10 @@ pub struct IsoMeta {
/// the common no-password case. /// the common no-password case.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub password_hash: Option<String>, pub password_hash: Option<String>,
/// Where the ISO sits in the PXE menu hierarchy — operator-controlled,
/// not driven by family detection. Defaults to [`IsoCategory::Os`].
#[serde(default)]
pub category: IsoCategory,
} }
impl IsoMeta { impl IsoMeta {
@@ -114,6 +142,7 @@ impl UploadHandle {
boot_entries, boot_entries,
source: IsoSource::Local, source: IsoSource::Local,
password_hash: None, password_hash: None,
category: IsoCategory::default(),
}; };
store.persist_meta(&meta).await?; store.persist_meta(&meta).await?;
store.insert(meta.clone()); store.insert(meta.clone());
@@ -216,6 +245,9 @@ impl IsoStore {
return Err(Error::Invalid(format!("iso '{id}' already exists"))); return Err(Error::Invalid(format!("iso '{id}' already exists")));
} }
let partial_path = self.iso_dir.join(format!("{id}.partial")); let partial_path = self.iso_dir.join(format!("{id}.partial"));
if partial_path.exists() {
return Err(Error::Invalid(format!("iso '{id}' is already uploading")));
}
let file = tokio::fs::File::create(&partial_path).await?; let file = tokio::fs::File::create(&partial_path).await?;
Ok(UploadHandle { Ok(UploadHandle {
id, id,
@@ -315,6 +347,7 @@ impl IsoStore {
boot_entries, boot_entries,
source, source,
password_hash: None, password_hash: None,
category: IsoCategory::default(),
}; };
self.inner.write().isos.insert(id, meta); self.inner.write().isos.insert(id, meta);
} }
@@ -374,6 +407,47 @@ impl IsoStore {
Ok(()) Ok(())
} }
/// Flip an ISO's menu category. Persists to `meta.json` for local
/// ISOs; NFS-sourced ISOs keep the change in memory only (the next
/// re-scan would overwrite it anyway).
pub async fn set_category(&self, id: &str, category: IsoCategory) -> Result<IsoMeta> {
let updated = {
let mut g = self.inner.write();
let m = g
.isos
.get_mut(id)
.ok_or_else(|| Error::Invalid(format!("no such iso '{id}'")))?;
m.category = category;
m.clone()
};
if matches!(updated.source, IsoSource::Local) {
self.persist_meta(&updated).await?;
}
Ok(updated)
}
/// Absolute path to the directory holding local ISO uploads. Used
/// by the HTTP layer for the disk-space endpoint — the volume that
/// hosts this directory is what runs out of room first.
#[must_use]
pub fn iso_dir(&self) -> PathBuf {
self.iso_dir.as_path().to_path_buf()
}
/// `(total_bytes, available_bytes)` for the filesystem hosting the
/// ISO directory. Returns `None` if `statvfs` fails (read-only
/// filesystem with no quota, mount disappeared, …) — callers
/// should treat that as "unknown" rather than zero.
///
/// Lives here rather than the HTTP crate because `http-api`'s
/// `#![forbid(unsafe_code)]` rules out the libc FFI directly, and
/// because this is naturally an `IsoStore` question — the volume
/// of interest is whatever's hosting the iso dir.
#[must_use]
pub fn disk_usage(&self) -> Option<(u64, u64)> {
disk_usage_for(self.iso_dir.as_path())
}
/// Verify a candidate password against the stored bcrypt hash. /// Verify a candidate password against the stored bcrypt hash.
/// Returns: /// Returns:
/// - `Ok(true)` — match (or the ISO has no password set; boot is open) /// - `Ok(true)` — match (or the ISO has no password set; boot is open)
@@ -390,6 +464,33 @@ impl IsoStore {
} }
} }
/// Resolve `(total, available)` bytes for the filesystem hosting `path`.
/// Returns `None` if `statvfs` fails.
#[allow(unsafe_code)]
fn disk_usage_for(path: &std::path::Path) -> Option<(u64, u64)> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let c = CString::new(path.as_os_str().as_bytes()).ok()?;
// SAFETY: `statvfs` is repr(C); a zeroed value is a valid initial
// state per POSIX. The FFI call writes every field we then read.
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
// SAFETY: `c` is a NUL-terminated C string pointing into a stack
// CString that outlives this call; `&mut stat` is a unique aligned
// pointer to a stack-local `statvfs`. The kernel writes through
// it but does not retain the pointer past return.
let rc = unsafe { libc::statvfs(c.as_ptr(), &raw mut stat) };
if rc != 0 {
return None;
}
// Use f_frsize (fundamental block size). f_bsize is "preferred I/O
// block" and doesn't always match the unit f_blocks is denominated
// in — on some BSDs it would over-report by a factor of 8.
let frsize = stat.f_frsize as u64;
let total = stat.f_blocks as u64 * frsize;
let avail = stat.f_bavail as u64 * frsize;
Some((total, avail))
}
fn slugify(filename: &str) -> String { fn slugify(filename: &str) -> String {
let stem = Path::new(filename) let stem = Path::new(filename)
.file_stem() .file_stem()
@@ -495,8 +596,17 @@ fn linux_cmdline(family: DistroFamily, id: &str) -> String {
// The HTTP layer resolves `${base-url}` at render time. // The HTTP layer resolves `${base-url}` at render time.
let iso_url = format!("${{base-url}}/iso/{id}.iso"); let iso_url = format!("${{base-url}}/iso/{id}.iso");
match family { match family {
// VMware-UEFI fix (v0.4.5, matching Bootimus v0.1.67's Casper
// patch): drop `netboot=url url=… ---` in favour of the
// canonical Casper option `iso-url=` and add `ds=nocloud` so
// cloud-init / subiquity (live-server) doesn't stall waiting on
// a metadata datasource that doesn't exist in PXE. Without
// `ds=nocloud`, Ubuntu live-server / Mint / Pop!_OS / elementary
// ISOs would boot fine on bare-metal UEFI but hang at "cloud-init
// running" on VMware-UEFI guests because the vmxnet3 driver's
// late-init upsets cloud-init's network probe.
DistroFamily::DebianUbuntu => format!( DistroFamily::DebianUbuntu => format!(
"boot=casper netboot=url url={iso_url} ip=dhcp ---" "boot=casper initrd=initrd ds=nocloud ip=dhcp iso-url={iso_url}"
), ),
DistroFamily::RhelFedora => format!( DistroFamily::RhelFedora => format!(
"inst.repo={iso_url} inst.stage2={iso_url} ip=dhcp" "inst.repo={iso_url} inst.stage2={iso_url} ip=dhcp"
@@ -531,6 +641,23 @@ mod tests {
assert_eq!(slugify("/etc/passwd"), "passwd"); assert_eq!(slugify("/etc/passwd"), "passwd");
} }
#[test]
fn casper_cmdline_vmware_uefi_safe() {
// v0.4.5 regression guard: the Debian/Ubuntu cmdline must use
// the canonical Casper `iso-url=` option and include
// `ds=nocloud` so VMware-UEFI guests don't hang at "cloud-init
// running" waiting on a metadata datasource that PXE can't
// provide. The legacy `netboot=url url=… ---` form is gone for
// good.
let s = linux_cmdline(DistroFamily::DebianUbuntu, "ubuntu-24-04");
assert!(s.contains("boot=casper"), "{s}");
assert!(s.contains("iso-url=${base-url}/iso/ubuntu-24-04.iso"), "{s}");
assert!(s.contains("ds=nocloud"), "{s}");
assert!(s.contains("ip=dhcp"), "{s}");
assert!(!s.contains("netboot=url"), "legacy option leaked: {s}");
assert!(!s.contains(" --- "), "stray ---: {s}");
}
fn fake_meta(id: &str) -> IsoMeta { fn fake_meta(id: &str) -> IsoMeta {
IsoMeta { IsoMeta {
id: id.into(), id: id.into(),
@@ -548,6 +675,7 @@ mod tests {
boot_entries: vec![], boot_entries: vec![],
source: IsoSource::Local, source: IsoSource::Local,
password_hash: None, password_hash: None,
category: IsoCategory::default(),
} }
} }
@@ -594,6 +722,19 @@ mod tests {
assert!(matches!(r, Err(Error::Invalid(_)))); assert!(matches!(r, Err(Error::Invalid(_))));
} }
#[tokio::test]
async fn begin_upload_rejects_existing_partial_file() {
let dir = tempdir().unwrap();
let store = IsoStore::new(dir.path().to_path_buf());
store.ensure_dirs().await.unwrap();
tokio::fs::write(dir.path().join("ubuntu.partial"), b"in-flight")
.await
.unwrap();
let r = store.begin_upload("ubuntu.iso").await;
assert!(matches!(r, Err(Error::Invalid(_))));
}
#[tokio::test] #[tokio::test]
async fn password_persists_via_meta_json_for_local_isos() { async fn password_persists_via_meta_json_for_local_isos() {
// Hash makes it onto disk so it survives a restart. // Hash makes it onto disk so it survives a restart.
+21 -2
View File
@@ -39,7 +39,7 @@ enum Command {
/// docker run --rm \ /// docker run --rm \
/// -v /my/isos:/seed:ro \ /// -v /my/isos:/seed:ro \
/// -v openpxe-data:/var/lib/openpxe/isos \ /// -v openpxe-data:/var/lib/openpxe/isos \
/// openpxe:0.3.2 seed --from /seed /// openpxe:0.4.1 seed --from /seed
Seed { Seed {
/// Source directory containing one or more `.iso` files. /// Source directory containing one or more `.iso` files.
#[arg(long)] #[arg(long)]
@@ -103,6 +103,11 @@ async fn main() -> anyhow::Result<()> {
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);
let hosts = HostBindings::load_or_default(&config.paths.work_dir); let hosts = HostBindings::load_or_default(&config.paths.work_dir);
let boot_log = openpxe_core::BootLog::load_or_default(&config.paths.work_dir);
let branding = openpxe_core::BrandingStore::load_or_default(&config.paths.work_dir);
let admin = openpxe_core::AdminStore::load_or_default(&config.paths.work_dir);
let sso = openpxe_core::SsoStore::load_or_default(&config.paths.work_dir);
let sessions = openpxe_http_api::auth::SessionStore::default();
let metrics = Metrics::new(); let metrics = Metrics::new();
// Build the SMB manager unconditionally — it starts/stops on the // Build the SMB manager unconditionally — it starts/stops on the
@@ -140,9 +145,15 @@ async fn main() -> anyhow::Result<()> {
settings: settings.clone(), settings: settings.clone(),
queue: queue.clone(), queue: queue.clone(),
hosts: hosts.clone(), hosts: hosts.clone(),
boot_log: boot_log.clone(),
branding: branding.clone(),
admin: admin.clone(),
sessions: sessions.clone(),
sso: sso.clone(),
metrics: metrics.clone(), metrics: metrics.clone(),
smb: Some(smb.clone()), smb: Some(smb.clone()),
nfs: nfs.clone(), nfs: nfs.clone(),
uploads: openpxe_http_api::uploads::UploadSessions::default(),
log_bus: log_bus.clone(), log_bus: log_bus.clone(),
started_at: time::OffsetDateTime::now_utc(), started_at: time::OffsetDateTime::now_utc(),
public_base_url: public_base_url.clone(), public_base_url: public_base_url.clone(),
@@ -156,7 +167,15 @@ async fn main() -> anyhow::Result<()> {
let http_task = tokio::spawn(async move { let http_task = tokio::spawn(async move {
let listener = tokio::net::TcpListener::bind(http_addr).await?; let listener = tokio::net::TcpListener::bind(http_addr).await?;
tracing::info!(target: "openpxe::http", "HTTP listening on {http_addr}"); tracing::info!(target: "openpxe::http", "HTTP listening on {http_addr}");
axum::serve(listener, router).await?; // `into_make_service_with_connect_info` is required so per-request
// `ConnectInfo<SocketAddr>` extractors can resolve the peer IP —
// used by `/boot/<entry>.ipxe` to record the booting client's
// address into the Host log. Without this the extractor 500s.
axum::serve(
listener,
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await?;
Ok::<_, anyhow::Error>(()) Ok::<_, anyhow::Error>(())
}); });
+356 -43
View File
@@ -8,37 +8,49 @@
* CSS lands). */ * CSS lands). */
:root { :root {
/* Dark palette (default). */ /* Jet-black dark palette (default). Modelled on Netbox Labs's
--bg: #0b1018; near-black product chrome, with surfaces stepping subtly upward
--bg-panel: #121826; rather than the previous blue-tinted ramp, so the UI reads as a
--bg-panel-2: #1a2334; genuine "dark" rather than "dim navy". */
--bg-elev: #223047; --bg: #030303;
--fg: #e4e8ef; --bg-panel: #0a0a0a;
--fg-dim: #8a94a7; --bg-panel-2: #141414;
--fg-dimmer: #5a6379; --bg-elev: #1c1c1c;
--accent: #00d4b4; /* Netbox-ish teal */ --fg: #e8eaed;
--fg-dim: #9aa0a6;
--fg-dimmer: #6b7077;
--accent: #00d4b4; /* Netbox-ish teal — kept for brand */
--accent-dim: #07a38c; --accent-dim: #07a38c;
--warn: #ffb347; --warn: #ffb347;
--err: #ef6e6e; --err: #ef6e6e;
--ok: #4ade80; --ok: #4ade80;
--border: #223047; --border: #1f1f1f;
--border-soft: #172033; --border-soft: #141414;
--terminal-bg: #06090e; --terminal-bg: #050505;
--shadow-card: 0 1px 0 rgba(255,255,255,0.02), 0 8px 24px rgba(0,0,0,0.25); --shadow-card: 0 1px 0 rgba(255,255,255,0.02), 0 8px 24px rgba(0,0,0,0.55);
--radius: 6px; --radius: 6px;
--radius-lg: 10px; --radius-lg: 10px;
--sidebar-w: 240px; --sidebar-w: 240px;
--topbar-h: 56px; --topbar-h: 56px;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
/* v0.4.63: tie native form-control rendering (checkboxes, scroll bars,
date pickers) to the active OpenPXE theme. Without this, the inline
`<meta name="color-scheme" content="dark light">` in index.html forces
dark form chrome in *both* themes — so the SSO "Enable single sign-on"
checkbox renders as an opaque black square against the light-mode
panel, ignoring our accent-color hint. CSS `color-scheme` overrides
the meta and tracks `data-theme` correctly. */
color-scheme: dark;
} }
:root[data-theme="light"] { :root[data-theme="light"] {
color-scheme: light;
/* Light palette — high-contrast neutral, accent unchanged for brand /* Light palette — high-contrast neutral, accent unchanged for brand
consistency. Designed against Netbox Labs's reference screenshot: consistency. Designed against Netbox Labs's reference screenshot:
near-white surfaces, soft grey dividers, dark text. */ near-white surfaces, soft grey dividers, dark text. */
--bg: #f6f8fb; --bg: #f6f8fb;
--bg-panel: #ffffff; --bg-panel: #fbfcfe;
--bg-panel-2: #f0f3f8; --bg-panel-2: #f0f3f8;
--bg-elev: #e6ebf2; --bg-elev: #e6ebf2;
--fg: #1c2330; --fg: #1c2330;
@@ -51,7 +63,11 @@
--ok: #1f9b54; --ok: #1f9b54;
--border: #d8dde6; --border: #d8dde6;
--border-soft: #e7eaf0; --border-soft: #e7eaf0;
--terminal-bg: #0d1219; /* terminal stays dark even in light mode */ /* Light-mode terminal: the pane background and chrome track the rest
of the light theme. Per-level text colours below recolour-on-light
so log lines stay readable on a pale background — previously the
terminal was locked to dark and looked like a stuck panel. */
--terminal-bg: #ffffff;
--shadow-card: 0 1px 0 rgba(0,0,0,0.02), 0 6px 18px rgba(20,28,52,0.06); --shadow-card: 0 1px 0 rgba(0,0,0,0.02), 0 6px 18px rgba(20,28,52,0.06);
} }
@@ -84,14 +100,23 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
display: flex; flex-direction: column; display: flex; flex-direction: column;
} }
/* The brand block sits flush with the topbar so the sidebar+topbar reads
as one continuous bar across the top of the app, rather than a chunky
2-line logo block plus a separate (smaller-typeface) page title. The
height/border-bottom match the topbar exactly so the divider runs
straight across without a step. */
.sidebar .brand { .sidebar .brand {
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 12px;
padding: 14px 18px; padding: 0 18px;
height: var(--topbar-h);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.sidebar .brand img { width: 40px; height: auto; } .sidebar .brand img { width: 26px; height: 26px; flex: none; }
.sidebar .brand strong { font-size: 16px; letter-spacing: 0.4px; } .sidebar .brand strong {
.sidebar .brand .sub { color: var(--fg-dim); font-size: 11px; } font-size: 15px; font-weight: 600;
letter-spacing: 0.2px;
color: var(--fg);
}
.sidebar nav { padding: 10px 0; flex: 1; overflow-y: auto; } .sidebar nav { padding: 10px 0; flex: 1; overflow-y: auto; }
.sidebar nav a { .sidebar nav a {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
@@ -113,10 +138,40 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
} }
.sidebar nav a.active .count { background: var(--accent); color: #002923; } .sidebar nav a.active .count { background: var(--accent); color: #002923; }
.sidebar .footer { .sidebar .footer {
padding: 10px 18px; border-top: 1px solid var(--border); padding: 12px 18px; border-top: 1px solid var(--border);
color: var(--fg-dimmer); font-size: 11px; color: var(--fg-dimmer); font-size: 11px;
display: flex; flex-direction: column; gap: 4px;
}
.sidebar .footer code { background: transparent; color: var(--fg-dim); padding: 0;
font-size: 11px; word-break: break-all; }
.sidebar .footer .status-row {
display: flex; align-items: center; gap: 8px;
margin-bottom: 4px;
}
.sidebar .footer .status-row .dot {
width: 8px; height: 8px; border-radius: 50%; display: inline-block;
background: var(--fg-dimmer); flex: none;
}
.sidebar .footer .status-row .dot.ok { background: var(--ok);
box-shadow: 0 0 6px color-mix(in srgb, var(--ok) 60%, transparent); }
.sidebar .footer .status-row .dot.err { background: var(--err); }
.sidebar .footer .status-row .dot.warn { background: var(--warn); }
.sidebar .footer .status-label { color: var(--fg-dim); }
.sidebar .footer .status-value { color: var(--fg); font-weight: 600; }
.sidebar .footer .status-value.ok { color: var(--ok); }
.sidebar .footer .status-value.err { color: var(--err); }
.sidebar .footer .status-value.warn { color: var(--warn); }
.sidebar .footer .footer-sub { color: var(--fg-dimmer); margin-top: 2px; }
/* Persistent backend identity. Sits below the advertised URL so even
when an operator has uploaded their own logo, "what is this" stays
answerable from the bottom-left of every page. */
.sidebar .footer .footer-version {
margin-top: 8px; padding-top: 8px;
border-top: 1px dashed var(--border-soft);
color: var(--fg-dim);
font-variant-numeric: tabular-nums;
letter-spacing: 0.2px;
} }
.sidebar .footer code { background: transparent; color: var(--fg-dim); padding: 0; }
/* ── Top bar ───────────────────────────────────────────────────────── */ /* ── Top bar ───────────────────────────────────────────────────────── */
@@ -230,7 +285,7 @@ button, .btn {
cursor: pointer; cursor: pointer;
transition: background 0.12s ease; transition: background 0.12s ease;
} }
button:hover, .btn:hover { background: var(--accent-dim); color: #fff; } button:hover, .btn:hover { background: var(--accent-dim); color: #f4fffd; }
button.ghost { background: transparent; color: var(--fg); border: 1px solid var(--border); } button.ghost { background: transparent; color: var(--fg); border: 1px solid var(--border); }
button.ghost:hover { background: var(--bg-panel-2); color: var(--fg); } button.ghost:hover { background: var(--bg-panel-2); color: var(--fg); }
button.danger { background: transparent; color: var(--err); border: 1px solid 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); }
@@ -241,23 +296,83 @@ label.field {
} }
label.field .name { color: var(--fg-dim); font-size: 12px; } label.field .name { color: var(--fg-dim); font-size: 12px; }
label.field .hint { color: var(--fg-dimmer); font-size: 11px; } label.field .hint { color: var(--fg-dimmer); font-size: 11px; }
label.field input[type="text"], /* All single-line inputs share one chrome rule. Pre-v0.4.6 we only
label.field input[type="number"], styled type=text/number, which left type=password fields rendering
with the default browser look — visibly off vs adjacent text fields
in the Account card. The negation list keeps `type=checkbox`,
`type=file`, and `type=range` (none of which we use inside
`label.field`) from picking up the padded-box look. */
label.field input:not([type="checkbox"]):not([type="file"]):not([type="range"]),
label.field select, label.field select,
label.field textarea { label.field textarea {
width: 100%; background: var(--bg); color: var(--fg); width: 100%; background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: var(--radius); border: 1px solid var(--border); border-radius: var(--radius);
padding: 7px 10px; font: inherit; padding: 7px 10px; font: inherit;
/* iOS/Safari shrinks password-field text by default; clamp it so
the password input matches the username input's metrics. */
font-size: 14px; line-height: 1.4;
box-shadow: none; -webkit-appearance: none; appearance: none;
}
/* v0.4.63: with `appearance: none`, the native <select> dropdown arrow
disappears, which makes the "Metadata source" pick-list look like a
plain (and slightly squished) text input. Paint our own chevron via
background-image so the control still reads as a dropdown, and reserve
right-padding for it. The data-URI SVG inherits currentColor via the
`stroke` attribute so the arrow follows light/dark theme without a
second declaration. */
label.field select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%239aa0a6' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><polyline points='1.5,1.5 6,6 10.5,1.5'/></svg>");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 11px 7px;
padding-right: 30px;
}
:root[data-theme="light"] label.field select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8' fill='none' stroke='%235a6377' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><polyline points='1.5,1.5 6,6 10.5,1.5'/></svg>");
} }
label.field input:focus, label.field select:focus, label.field textarea:focus { label.field input:focus, label.field select:focus, label.field textarea:focus {
outline: none; border-color: var(--accent); outline: none; border-color: var(--accent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
} }
label.check { label.check {
display: flex; gap: 10px; align-items: center; display: flex; gap: 10px; align-items: center;
padding: 8px 10px; margin-bottom: 6px; padding: 8px 10px; margin-bottom: 6px;
border: 1px solid var(--border-soft); border-radius: var(--radius); border: 1px solid var(--border-soft); border-radius: var(--radius);
} }
label.check input { accent-color: var(--accent); } /* v0.4.63: native checkboxes used to render as opaque black squares in
light mode because the page meta declares `color-scheme: dark light`
and `accent-color` alone only repaints the *check mark* (not the
container). Take full control of the chrome so the box reads cleanly
on both palettes and the checked state lights up in our accent. */
label.check input[type="checkbox"] {
appearance: none; -webkit-appearance: none;
width: 16px; height: 16px; flex: none;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
display: inline-grid; place-content: center;
cursor: pointer; margin: 0;
transition: background 0.1s ease, border-color 0.1s ease;
}
label.check input[type="checkbox"]:hover { border-color: var(--accent); }
label.check input[type="checkbox"]:checked {
background: var(--accent);
border-color: var(--accent);
}
label.check input[type="checkbox"]:checked::after {
/* Classic ✓ glyph built from a rotated rectangle border. Colour is
#002923 (the same near-black we use on solid-accent buttons) so the
tick stays legible against the teal fill in both themes. */
content: '';
width: 4px; height: 8px;
border: solid #002923;
border-width: 0 2px 2px 0;
transform: rotate(45deg) translate(-1px, -1px);
}
label.check input[type="checkbox"]:focus-visible {
outline: none;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
}
/* ── Drop zone ────────────────────────────────────────────────────── */ /* ── Drop zone ────────────────────────────────────────────────────── */
@@ -375,9 +490,21 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
.dot.err { background: var(--err); } .dot.err { background: var(--err); }
.dot.warn { background: var(--warn); } .dot.warn { background: var(--warn); }
/* Inline form rows. */ /* Inline form rows. The default is a 4-column grid sized for the
.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; } Account card's "Current / New username / New password / Confirm"
@media (max-width: 900px) { .form-row { grid-template-columns: 1fr; } } quartet; the `.cols-3` modifier swaps to a 3-column layout for the
SSO header strip (display name / logo URL / metadata source). All
`.form-row > label.field` children share the same baseline because
their inner inputs share metrics via the global rule above. */
.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; align-items: end; }
.form-row.cols-3 { grid-template-columns: repeat(3, 1fr); }
.form-row.cols-2 { grid-template-columns: repeat(2, 1fr); }
.form-row label.field { margin-bottom: 0; }
@media (max-width: 900px) {
.form-row,
.form-row.cols-3,
.form-row.cols-2 { grid-template-columns: 1fr; }
}
/* ── Queued deployment visual ────────────────────────────────────── */ /* ── Queued deployment visual ────────────────────────────────────── */
.queue-track { .queue-track {
@@ -413,52 +540,238 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
min-height: 480px; min-height: 480px;
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
} }
/* Terminal pane colours follow the active theme. Hard-coded hexes
(#050505, #181818, #cfd6e2 etc.) were leaving the light-mode pane
looking dark; we keep palette-aware vars instead so the toggle works. */
.terminal .pane { .terminal .pane {
flex: 1; overflow: auto; flex: 1; overflow: auto;
padding: 10px 14px; padding: 10px 14px;
font-family: var(--mono); font-size: 12.5px; line-height: 1.5; font-family: var(--mono); font-size: 12.5px; line-height: 1.5;
color: #cfd6e2; color: var(--fg);
white-space: pre-wrap; word-break: break-word; white-space: pre-wrap; word-break: break-word;
} }
.terminal .pane .lvl-error { color: var(--err); } .terminal .pane .lvl-error { color: var(--err); }
.terminal .pane .lvl-warn { color: var(--warn); } .terminal .pane .lvl-warn { color: var(--warn); }
.terminal .pane .lvl-info { color: #cfd6e2; } .terminal .pane .lvl-info { color: var(--fg); }
.terminal .pane .lvl-debug { color: #8b94a8; } .terminal .pane .lvl-debug { color: var(--fg-dim); }
.terminal .pane .lvl-trace { color: #5a6379; } .terminal .pane .lvl-trace { color: var(--fg-dimmer); }
.terminal .pane .ts { color: #5a6379; } .terminal .pane .ts { color: var(--fg-dimmer); }
.terminal .pane .tg { color: #7cd3ff; } .terminal .pane .tg { color: var(--accent); }
.terminal .pane .echo { color: var(--accent); } .terminal .pane .echo { color: var(--accent); }
.terminal .input-row { .terminal .input-row {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
padding: 8px 14px; padding: 8px 14px;
background: #0a0e15; background: var(--bg-panel-2);
border-top: 1px solid #1d2330; border-top: 1px solid var(--border);
} }
.terminal .input-row .prompt { color: var(--accent); font-family: var(--mono); } .terminal .input-row .prompt { color: var(--accent); font-family: var(--mono); }
.terminal .input-row input { .terminal .input-row input {
flex: 1; background: transparent; border: 0; color: #e4e8ef; flex: 1; background: transparent; border: 0; color: var(--fg);
font: inherit; font-family: var(--mono); font-size: 13px; font: inherit; font-family: var(--mono); font-size: 13px;
outline: none; padding: 4px 0; outline: none; padding: 4px 0;
} }
.terminal .toolbar { .terminal .toolbar {
display: flex; gap: 8px; align-items: center; display: flex; gap: 8px; align-items: center;
padding: 8px 14px; padding: 8px 14px;
background: #0a0e15; background: var(--bg-panel-2);
border-bottom: 1px solid #1d2330; border-bottom: 1px solid var(--border);
font-size: 12px; color: #8a94a7; font-size: 12px; color: var(--fg-dim);
} }
.terminal .toolbar .right { margin-left: auto; display: flex; gap: 6px; } .terminal .toolbar .right { margin-left: auto; display: flex; gap: 6px; }
.terminal .toolbar button { .terminal .toolbar button {
padding: 3px 9px; font-size: 11px; padding: 3px 9px; font-size: 11px;
background: transparent; color: #8a94a7; border: 1px solid #1d2330; background: transparent; color: var(--fg-dim); border: 1px solid var(--border);
font-weight: 500; font-weight: 500;
} }
.terminal .toolbar button:hover { color: #e4e8ef; background: #1d2330; } .terminal .toolbar button:hover { color: var(--fg); background: var(--bg-elev); }
/* ── Auth screen (first-run setup + login) ───────────────────────
Used when /api/me reports setup_required or !authenticated. The
regular .shell is hidden; this overlay takes the full viewport so
the operator never sees half-loaded dashboard chrome while the auth
state is unknown. Same palette as the rest of the UI — borrows the
Sonarr/Radarr layout (centered narrow card on the page background).
*/
.auth-screen {
position: fixed; inset: 0;
display: flex; align-items: center; justify-content: center;
background: var(--bg);
padding: 24px;
z-index: 100;
}
.auth-card {
width: 100%; max-width: 380px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 28px 28px 22px;
}
.auth-card .brand-row {
display: flex; align-items: center; gap: 12px;
margin-bottom: 18px;
}
.auth-card .brand-row img { width: 32px; height: 32px; flex: none; }
.auth-card .brand-row .name { font-size: 17px; font-weight: 600; letter-spacing: 0.2px; color: var(--fg); }
.auth-card h2 {
margin: 0 0 6px; font-size: 16px; font-weight: 600; color: var(--fg);
}
.auth-card .lede {
color: var(--fg-dim); font-size: 13px; margin: 0 0 18px;
line-height: 1.5;
}
.auth-card .field { margin-bottom: 12px; }
.auth-card input[type="text"],
.auth-card input[type="password"] {
width: 100%; background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 9px 11px; font: inherit; font-size: 13.5px;
}
.auth-card input:focus { outline: none; border-color: var(--accent); }
.auth-card .submit { width: 100%; padding: 9px 12px; margin-top: 6px; }
.auth-card .auth-err {
margin-top: 12px; color: var(--err); font-size: 12.5px;
}
.auth-card .auth-foot {
margin-top: 14px; padding-top: 12px;
border-top: 1px solid var(--border-soft);
color: var(--fg-dimmer); font-size: 11.5px; text-align: center;
}
.auth-card .sso-btn {
width: 100%; margin-top: 10px;
background: transparent; color: var(--fg);
border: 1px solid var(--border);
padding: 9px 12px;
}
.auth-card .sso-btn:hover {
background: var(--bg-panel-2); border-color: var(--accent); color: var(--fg);
}
.auth-card .sso-btn .meta { color: var(--fg-dim); font-size: 11px; margin-top: 2px; }
/* ── Top-right user menu (v0.4.6) ────────────────────────────
The "signed in as X" identity + sign-out moved out of the sidebar
footer in v0.4.6 — the sidebar footer is now reserved for the
service-state trio (Service status / Advertised URL / Backend
version). The button matches the theme toggle's size + chrome so
the top-right reads as a tidy two-icon strip. */
.user-menu { position: relative; }
.user-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 36px; height: 32px;
background: transparent; color: var(--fg);
border: 1px solid var(--border); border-radius: 8px;
cursor: pointer; padding: 0;
transition: background 0.15s ease, border-color 0.15s ease;
}
.user-btn:hover { background: var(--bg-panel-2); border-color: var(--accent); }
.user-pop {
position: absolute; right: 0; top: 38px;
min-width: 200px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 6px;
z-index: 60;
display: flex; flex-direction: column; gap: 2px;
}
.user-pop[hidden] { display: none; }
.user-pop .user-pop-name {
padding: 8px 10px 6px;
border-bottom: 1px solid var(--border-soft);
margin-bottom: 4px;
color: var(--fg); font-weight: 600; font-size: 13px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.user-pop .user-pop-item {
text-align: left; width: 100%;
background: transparent; color: var(--fg);
border: 0; border-radius: var(--radius);
padding: 7px 10px; font: inherit; font-size: 13px; font-weight: 500;
cursor: pointer;
}
.user-pop .user-pop-item:hover {
background: var(--bg-panel-2); color: var(--fg);
}
.user-pop .user-pop-danger { color: var(--err); }
.user-pop .user-pop-danger:hover {
background: color-mix(in srgb, var(--err) 12%, transparent);
color: var(--err);
}
/* ── About card ─────────────────────────────────────────────────── */ /* ── About card ─────────────────────────────────────────────────── */
.about-hero { padding: 20px 24px; } .about-hero { padding: 20px 24px; }
.about-hero h2 { font-size: 22px; margin: 0 0 8px; color: var(--fg); } .about-hero h2 { font-size: 22px; margin: 0 0 8px; color: var(--fg); }
.about-hero .lead { color: var(--fg-dim); font-size: 14px; max-width: 60ch; } /* Span the full main column rather than capping at 60ch — the page is
read at typical desktop widths and the cap was leaving the right two
thirds of the panel awkwardly empty. */
.about-hero .lead { color: var(--fg-dim); font-size: 14px; max-width: none; }
.about-hero .who { margin-top: 18px; font-size: 13px; } .about-hero .who { margin-top: 18px; font-size: 13px; }
.about-hero .who span { color: var(--fg-dim); } .about-hero .who span { color: var(--fg-dim); }
.about-hero .who strong { color: var(--accent); } .about-hero .who strong { color: var(--accent); }
.about-hero a { color: var(--accent); }
/* ── API reference (Settings → bottom) ─────────────────────────── */
.api-ref { display: grid; gap: 18px; padding: 16px; }
.api-ref .group h3 {
margin: 0 0 8px; font-size: 13px; color: var(--fg-dim);
text-transform: uppercase; letter-spacing: 0.8px;
}
.api-ref .ep {
display: grid; grid-template-columns: 64px minmax(200px, 1fr) 2fr;
gap: 12px; align-items: baseline;
padding: 6px 0; border-top: 1px solid var(--border-soft);
font-size: 13px;
}
.api-ref .ep:first-child { border-top: 0; }
.api-ref .ep .method {
font-family: var(--mono); font-weight: 600; font-size: 11px;
padding: 2px 6px; border-radius: 4px;
text-align: center; letter-spacing: 0.6px;
}
.api-ref .ep .method.get { background: color-mix(in srgb, var(--ok) 22%, transparent); color: var(--ok); }
.api-ref .ep .method.post { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--accent); }
.api-ref .ep .method.put { background: color-mix(in srgb, var(--warn) 22%, transparent); color: var(--warn); }
.api-ref .ep .method.delete { background: color-mix(in srgb, var(--err) 22%, transparent); color: var(--err); }
.api-ref .ep .path { font-family: var(--mono); color: var(--fg); word-break: break-all; }
.api-ref .ep .desc { color: var(--fg-dim); }
@media (max-width: 900px) {
.api-ref .ep { grid-template-columns: 1fr; gap: 4px; }
.api-ref .ep .method { justify-self: start; }
}
/* ── Disk space card ───────────────────────────────────────────── */
.diskbar {
height: 10px; border-radius: 5px;
background: var(--bg-elev);
overflow: hidden; margin-top: 8px;
}
.diskbar .fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-dim), var(--accent));
transition: width 0.4s ease;
}
.diskbar.warn .fill { background: var(--warn); }
.diskbar.full .fill { background: var(--err); }
.disk-meta { display: flex; gap: 14px; font-size: 12px; color: var(--fg-dim); margin-top: 8px; flex-wrap: wrap; }
.disk-meta strong { color: var(--fg); font-weight: 600; font-variant-numeric: tabular-nums; }
/* ── Logo upload (Settings) ────────────────────────────────────── */
.logo-preview {
display: flex; align-items: center; gap: 14px;
padding: 12px;
background: var(--bg-panel-2);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.logo-preview .swatch {
width: 56px; height: 56px;
display: flex; align-items: center; justify-content: center;
background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius);
flex: none;
}
.logo-preview .swatch img { max-width: 48px; max-height: 48px; }
.logo-preview .info { flex: 1; min-width: 0; }
.logo-preview .info .name { color: var(--fg); font-weight: 600; }
.logo-preview .info .meta { color: var(--fg-dim); font-size: 12px; margin-top: 2px; }
+881 -46
View File
File diff suppressed because it is too large Load Diff
+44 -10
View File
@@ -5,8 +5,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" /> <meta name="color-scheme" content="dark light" />
<title>OpenPXE</title> <title>OpenPXE</title>
<link rel="stylesheet" href="/assets/app.css" /> <!-- v0.4.61: the `?v=…` query string is replaced by the server at
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg" /> request time with the running OpenPXE version. That guarantees a
fresh URL on every upgrade so browsers (and intermediary proxies)
can't keep serving stale JS / CSS / branding from before the
deploy. Combined with `Cache-Control: no-cache, must-revalidate`
on the asset handlers, the practical caching window is one
version. -->
<link rel="stylesheet" href="/assets/app.css?v={{ASSET_VERSION}}" />
<link rel="icon" type="image/svg+xml" href="/assets/logo.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. -->
@@ -26,11 +33,8 @@
<div class="shell"> <div class="shell">
<aside class="sidebar"> <aside class="sidebar">
<div class="brand"> <div class="brand">
<img src="/assets/logo.svg" alt="" /> <img src="/assets/logo.svg?v={{ASSET_VERSION}}" alt="OpenPXE" />
<div> <strong>OpenPXE</strong>
<strong>OpenPXE</strong>
<div class="sub">v<span data-bind="version">0.3.2</span></div>
</div>
</div> </div>
<nav> <nav>
<a data-view="dashboard" class="active">Dashboard</a> <a data-view="dashboard" class="active">Dashboard</a>
@@ -48,18 +52,27 @@
<span class="count" data-bind="host_count">0</span> <span class="count" data-bind="host_count">0</span>
</a> </a>
<a data-view="terminal">Terminal</a> <a data-view="terminal">Terminal</a>
<a data-view="settings">Settings</a>
<a data-view="about">About</a> <a data-view="about">About</a>
</nav> </nav>
<div class="footer"> <div class="footer">
Advertised to clients<br/> <div class="status-row">
<span class="dot" data-bind="ready_dot" title="Server readiness"></span>
<span class="status-label">Service status:</span>
<span class="status-value" data-bind="ready_label">checking…</span>
</div>
<div class="footer-sub">Advertised to clients</div>
<code>{{BASE_URL}}</code> <code>{{BASE_URL}}</code>
<!-- The brand badge at the top can be overridden by operator-uploaded
logos; keep "OpenPXE v…" pinned in the footer so the backend
identity is always visible regardless of branding. -->
<div class="footer-version">OpenPXE&nbsp;v<span data-bind="version">0.4.63</span></div>
</div> </div>
</aside> </aside>
<header class="topbar"> <header class="topbar">
<h1 data-bind="view_title">Dashboard</h1> <h1 data-bind="view_title">Dashboard</h1>
<div class="spacer"></div> <div class="spacer"></div>
<span class="chip" data-bind="ready_chip" title="Server readiness">checking…</span>
<span class="chip"><strong data-bind="iso_count2">0</strong>&nbsp;images</span> <span class="chip"><strong data-bind="iso_count2">0</strong>&nbsp;images</span>
<span class="chip"><strong data-bind="client_count2">0</strong>&nbsp;clients</span> <span class="chip"><strong data-bind="client_count2">0</strong>&nbsp;clients</span>
<span class="chip"><strong data-bind="queue_count2">0</strong>&nbsp;in queue</span> <span class="chip"><strong data-bind="queue_count2">0</strong>&nbsp;in queue</span>
@@ -83,11 +96,32 @@
<path d="M20.5 14A8 8 0 0 1 10 3.5 a8 8 0 1 0 10.5 10.5z"/> <path d="M20.5 14A8 8 0 0 1 10 3.5 a8 8 0 1 0 10.5 10.5z"/>
</svg> </svg>
</button> </button>
<!-- v0.4.6: signed-in operator menu. Sits next to the theme toggle
in the top-right corner so the sidebar footer stays clean for
the "Service status / Advertised URL / Backend version" trio.
The whole block is hidden until /api/me confirms a session. -->
<div class="user-menu" data-bind="user_menu_wrap" style="display:none">
<button id="user-menu-btn" class="user-btn" type="button"
aria-label="Account menu" aria-haspopup="true" aria-expanded="false"
title="Account">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="8" r="3.6"/>
<path d="M4.5 20a7.5 7.5 0 0 1 15 0"/>
</svg>
</button>
<div id="user-menu-pop" class="user-pop" data-bind="user_menu_pop" hidden>
<div class="user-pop-name" data-bind="user_pop_name"></div>
<button type="button" class="user-pop-item" data-bind="user_pop_edit">Edit account</button>
<button type="button" class="user-pop-item user-pop-danger" data-bind="user_pop_logout">Sign out</button>
</div>
</div>
</header> </header>
<main class="main" id="view-root"></main> <main class="main" id="view-root"></main>
</div> </div>
<script src="/assets/app.js"></script> <script src="/assets/app.js?v={{ASSET_VERSION}}"></script>
</body> </body>
</html> </html>
+14 -4
View File
@@ -7,11 +7,21 @@
//! nav, top bar with secondary tabs, card-dense content panels. //! nav, top bar with secondary tabs, card-dense content panels.
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
/// Render the top-level page. `base_url` is interpolated into the footer /// Render the top-level page.
/// so operators can see at a glance what URL clients are PXE-booting from. ///
/// * `base_url` is interpolated into the footer so operators can see at
/// a glance what URL clients are PXE-booting from.
/// * `asset_version` is appended as `?v=…` to every asset URL so each
/// release ships with brand-new asset URLs — browsers (and any
/// intermediary proxy) can't keep serving last release's `app.js`
/// when we know the new one is incompatible. Combined with
/// `Cache-Control: no-cache, must-revalidate` on the asset handlers,
/// the worst-case caching window is one version.
#[must_use] #[must_use]
pub fn index_html(base_url: &str) -> String { pub fn index_html(base_url: &str, asset_version: &str) -> String {
INDEX_HTML.replace("{{BASE_URL}}", base_url) INDEX_HTML
.replace("{{BASE_URL}}", base_url)
.replace("{{ASSET_VERSION}}", asset_version)
} }
#[must_use] #[must_use]
+57 -22
View File
@@ -14,9 +14,17 @@
# Debian slim at ~75 MB + binary ~25 MB is fine for a PXE server that # Debian slim at ~75 MB + binary ~25 MB is fine for a PXE server that
# spends most of its life idle. # spends most of its life idle.
ARG RUST_VERSION=1.82 ARG RUST_VERSION=1.95
########## fetch iPXE binaries ########## ########## fetch iPXE binaries + wimboot ##########
# v0.4.62: kept on the boot.ipxe.org pre-builds for the moment. We
# want PNG support (so `console --picture` paints the operator's logo
# on the PXE menu) but the obvious path — adding a new `ipxe-build`
# stage that compiles iPXE from source with `IMAGE_PNG` enabled —
# runs into a QEMU/gcc instability when cross-emulating x86_64 on
# arm64 build hosts (intermittent `cc1` segfaults). The compositor
# at /branding/pxe-logo is already wired so when the iPXE rebuild
# lands (on native x86_64 hardware), no other code change is needed.
FROM debian:12-slim AS fetch FROM debian:12-slim AS fetch
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -28,23 +36,44 @@ RUN mkdir -p assets/ipxe && bash scripts/fetch-ipxe.sh
FROM rust:${RUST_VERSION}-bookworm AS build FROM rust:${RUST_VERSION}-bookworm AS build
WORKDIR /src WORKDIR /src
# v0.4.5: build a fully static musl binary (matches Bootimus v0.1.70's
# move). The resulting `/openpxe` has no glibc dependency at all, which:
# - Lets the runtime stage be any Linux distro (we still ship Debian
# slim for the `samba` / `wimtools` / `nfs-common` shellouts, but a
# scratch/distroless variant becomes a one-line swap).
# - Cuts a class of "GLIBC_2.39 not found" surprises when running on
# older RHEL/Rocky hosts that don't match Debian 12's libc version.
# - Sidesteps cross-compilation snags (the binary is its own world).
#
# x86_64-unknown-linux-musl is fully static by default (no extra
# RUSTFLAGS needed). musl-tools provides the linker.
RUN apt-get update \
&& apt-get install -y --no-install-recommends musl-tools \
&& rm -rf /var/lib/apt/lists/* \
&& rustup target add x86_64-unknown-linux-musl
# Copy the whole workspace in one go. We used to do a two-pass "cache-prime # Copy the whole workspace in one go. We used to do a two-pass "cache-prime
# with stubs, then real build" dance for dep-compile reuse; that turned out # with stubs, then real build" dance for dep-compile reuse; that turned out
# to silently serve stale stub binaries when cargo's fingerprint didn't # to silently serve stale stub binaries when cargo's fingerprint didn't
# notice the source swap. A single build is ~1.5 min longer on cold cache # notice the source swap. A single build is ~1.5 min longer on cold cache
# but guarantees the binary reflects the sources we copied. # but guarantees the binary reflects the sources we copied.
COPY Cargo.toml rust-toolchain.toml ./ # Do not copy rust-toolchain.toml into the image. The local workspace pins
# developer tooling, but inside Docker we intentionally use the Rust version
# selected by the base image. Copying rust-toolchain.toml with
# `channel = "stable"` makes rustup download a second full toolchain during
# `cargo build`, which is slow and can exhaust small Colima/CI disks.
COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/ COPY crates/ crates/
COPY --from=fetch /src/assets/ipxe /src/assets/ipxe COPY --from=fetch /src/assets/ipxe /src/assets/ipxe
# Cache cargo registry + target across builds. The `--no-edit` touch is # Cache cargo registry + target across builds. The mtime touch is
# belt-and-suspenders: cargo occasionally misses mtime-only changes on # belt-and-suspenders: cargo occasionally misses mtime-only changes on
# networked FS; this forces a fingerprint check. # networked FS; this forces a fingerprint check.
RUN --mount=type=cache,target=/usr/local/cargo/registry \ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/src/target,sharing=locked \ --mount=type=cache,target=/src/target,sharing=locked \
find crates -name '*.rs' -exec touch {} + && \ find crates -name '*.rs' -exec touch {} + && \
cargo build --release --bin openpxe && \ cargo build --release --target x86_64-unknown-linux-musl --bin openpxe && \
cp target/release/openpxe /openpxe && \ cp target/x86_64-unknown-linux-musl/release/openpxe /openpxe && \
ls -l /openpxe ls -l /openpxe
########## runtime ########## ########## runtime ##########
@@ -57,22 +86,28 @@ RUN apt-get update \
&& useradd --system --uid 10001 --home-dir /var/lib/openpxe --shell /usr/sbin/nologin openpxe \ && useradd --system --uid 10001 --home-dir /var/lib/openpxe --shell /usr/sbin/nologin openpxe \
&& mkdir -p /var/lib/openpxe/isos /var/lib/openpxe/work /var/lib/openpxe/smb \ && mkdir -p /var/lib/openpxe/isos /var/lib/openpxe/work /var/lib/openpxe/smb \
&& chown -R openpxe:openpxe /var/lib/openpxe && chown -R openpxe:openpxe /var/lib/openpxe
# Runtime deps explained: # v0.4.5: the openpxe binary itself is now built against musl and is
# wimtools - provides `wimlib-imagex`, used to inject startnet.cmd into boot.wim. # fully static — no glibc dependency. The runtime stage still ships
# samba - `smbd` serves extracted Windows install media on :445 for WinPE # Debian slim because OpenPXE shells out to the four packages below for
# to `net use`. Guest read-only, scoped to /var/lib/openpxe/smb. # functionality we deliberately don't reimplement in-process:
# nfs-common - provides `mount.nfs` / `mount.nfs4` for the Storage tab's # wimtools - `wimlib-imagex`, used to inject startnet.cmd into boot.wim.
# NFS share manager. Mount also requires the container to run # samba - `smbd` serves extracted Windows install media on :445 so
# with CAP_SYS_ADMIN — without it, mount(2) returns EPERM and # WinPE can `net use`. Guest read-only, scoped to
# the manager surfaces a clear error in the UI instead of # /var/lib/openpxe/smb.
# failing silently. # nfs-common - `mount.nfs` / `mount.nfs4` for the Storage tab's NFS
# iproute2 - `ip addr` / `ip route` for the auto-detected Network tab # share manager. Mount requires CAP_SYS_ADMIN; without it
# fields (NIC name, subnet mask, default gateway). Tiny, # mount(2) returns EPERM and the manager surfaces a clear
# always available; we don't pull in netlink crates for # error in the UI.
# this one-shot startup probe. # iproute2 - `ip addr` / `ip route` for the auto-detected Network
# gosu - drops privileges cleanly from root after the entrypoint fixes # tab fields (NIC name, subnet mask, default gateway).
# bind-mount ownership (common OpenShift/Docker UX issue). # Tiny, always available; we don't pull in netlink crates
# Windows-specific tools only activate when the WebUI toggle is on. # for this one-shot startup probe.
# gosu - drops privileges cleanly from root after the entrypoint
# fixes bind-mount ownership (common OpenShift/Docker UX
# issue).
# A future "openpxe-static" variant could drop everything except the
# binary onto distroless once we move the Windows + NFS legs to
# in-process Rust crates.
COPY --from=build /openpxe /usr/local/bin/openpxe COPY --from=build /openpxe /usr/local/bin/openpxe
COPY deploy/docker/entrypoint.sh /usr/local/bin/entrypoint.sh COPY deploy/docker/entrypoint.sh /usr/local/bin/entrypoint.sh
+1 -1
View File
@@ -34,7 +34,7 @@ spec:
fsGroup: 10001 fsGroup: 10001
containers: containers:
- name: openpxe - name: openpxe
image: gitea.milesward.dev/mward4/openpxe:0.3.2 image: gitea.milesward.dev/mward4/openpxe:0.4.1
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- name: dhcp - name: dhcp
+7 -7
View File
@@ -6,7 +6,7 @@ boot from OpenPXE". Pick the one that matches what you have.
## Path A — build on Unraid, push to Gitea registry, pull by tag ## Path A — build on Unraid, push to Gitea registry, pull by tag
Recommended once you've done it once. Image is published to Recommended once you've done it once. Image is published to
`gitea.milesward.dev/mward4/openpxe:0.3.2` (or your equivalent) and `gitea.milesward.dev/mward4/openpxe:0.4.1` (or your equivalent) and
every Unraid template / docker-compose just references the tag. every Unraid template / docker-compose just references the tag.
Pre-flight: Pre-flight:
@@ -40,14 +40,14 @@ What it does:
3. `docker build` against `deploy/docker/Dockerfile`. 3. `docker build` against `deploy/docker/Dockerfile`.
4. `docker login gitea.milesward.dev:3000` using a temp `DOCKER_CONFIG` 4. `docker login gitea.milesward.dev:3000` using a temp `DOCKER_CONFIG`
so the credential never lands in your real `~/.docker/config.json`. so the credential never lands in your real `~/.docker/config.json`.
5. `docker push` both `:0.3.2` and `:latest`. 5. `docker push` both `:0.4.1` and `:latest`.
6. Logout, scrub the temp config, delete the workspace. 6. Logout, scrub the temp config, delete the workspace.
After it finishes, in Unraid → Docker → Add Container, set: After it finishes, in Unraid → Docker → Add Container, set:
| Field | Value | | Field | Value |
|------------|-------------------------------------------------| |------------|-------------------------------------------------|
| Repository | `gitea.milesward.dev/mward4/openpxe:0.3.2` | | Repository | `gitea.milesward.dev/mward4/openpxe:0.4.1` |
| Network | `host` | | Network | `host` |
| Extra args | `--cap-add=NET_BIND_SERVICE` | | Extra args | `--cap-add=NET_BIND_SERVICE` |
@@ -88,14 +88,14 @@ then:
```bash ```bash
# On the build host # On the build host
docker save openpxe:0.3.2 | gzip > openpxe-0.3.2.tar.gz docker save openpxe:0.4.1 | gzip > openpxe-0.4.1.tar.gz
# Transfer (rsync / scp / SMB / ZFS-replicate / sneakernet) # Transfer (rsync / scp / SMB / ZFS-replicate / sneakernet)
scp openpxe-0.3.2.tar.gz root@unraid:/tmp/ scp openpxe-0.4.1.tar.gz root@unraid:/tmp/
# On Unraid # On Unraid
gunzip -c /tmp/openpxe-0.3.2.tar.gz | docker load gunzip -c /tmp/openpxe-0.4.1.tar.gz | docker load
docker tag openpxe:0.3.2 gitea.milesward.dev/mward4/openpxe:0.3.2 docker tag openpxe:0.4.1 gitea.milesward.dev/mward4/openpxe:0.4.1
``` ```
If you want it pullable by tag from other Unraid templates, push to If you want it pullable by tag from other Unraid templates, push to
+1 -1
View File
@@ -1,6 +1,6 @@
# Phase 6 — recommendations # Phase 6 — recommendations
The v0.3.2 cut leaves OpenPXE in a state where the entire protocol stack The v0.4.1 cut leaves OpenPXE in a state where the entire protocol stack
and operator UI are exercised by the automated test suite, the container is and operator UI are exercised by the automated test suite, the container is
multi-arch buildable, and the image ships at ~97 MB. What's left before multi-arch buildable, and the image ships at ~97 MB. What's left before
this looks and feels like a 1.0 product is mostly **real-hardware this looks and feels like a 1.0 product is mostly **real-hardware
+3 -4
View File
@@ -265,10 +265,9 @@ tab is one click from the brand bar.
- Persisted to `<work_dir>/hosts.json`. Like `SettingsStore`, in-memory - Persisted to `<work_dir>/hosts.json`. Like `SettingsStore`, in-memory
is authoritative — disk corruption falls back to empty rather than is authoritative — disk corruption falls back to empty rather than
failing startup. failing startup.
- Inspired by Tinkerbell `smee`'s MAC-prepended URL pattern. The DHCP - The DHCP reply embeds `?mac=${mac}` in the boot.ipxe URL; iPXE
reply now embeds `?mac=${mac}` in the boot.ipxe URL; iPXE substitutes substitutes the literal MAC client-side, so the HTTP layer can
the literal MAC client-side, so the HTTP layer can short-circuit short-circuit past the menu when a binding exists.
past the menu when a binding exists.
- `/api/hosts` GET / POST / DELETE drives the **Hosts** tab. - `/api/hosts` GET / POST / DELETE drives the **Hosts** tab.
**Prometheus metrics** (`crates/core/src/metrics.rs`): **Prometheus metrics** (`crates/core/src/metrics.rs`):
+106
View File
@@ -0,0 +1,106 @@
# PXE menu theme — research for next-release follow-up
Status: queued. v0.4.63 keeps the ASCII-banner fallback + `console --picture`
compositor wired; this note captures the design for the menu-theming work
that lands once iPXE rebuilt with `IMAGE_PNG` is published.
## How iVentoy actually does it
iVentoy is closed-source for its menu, but the supporting bits are
public at https://github.com/ventoy/PXE — a vanilla iPXE snapshot
(`iPXE/ipxe-bd13697`) used to produce the loader binaries iVentoy
serves over TFTP (`pxeboot.efi`, `iventoy_loader_16000`,
`iventoy_loader_16000_uefi`).
The graphical menu itself is rendered by iPXE's framebuffer console
with a baked-in PNG background via `console --picture` — same
primitive OpenPXE already uses in `crates/http-api/src/ipxe_script.rs`.
Evidence:
- The iPXE build in `ventoy/PXE` is configured with `CONSOLE_FRAMEBUFFER`
+ `IMAGE_PNG` + `CONSOLE_CMD` (the three flags `console --picture`
needs).
- iVentoy issue #11 confirms "iventoy using default 1024x768"; users
report 800x600 / 1024x768 / 1280x720 / 1280x1024 / 1920x1080 as
selectable resolutions from the iVentoy web UI **Configuration tab**,
not via EDID auto-detect. iPXE has no EDID parsing; the daemon writes
a resolution-tagged script per boot and serves the matching PNG.
- iVentoy docs explicitly state both Free and Pro editions **do not
support** modifying the boot background/title — it's baked into the
shipped PNG assets.
- Chrome is iPXE's native `menu` / `item` / `choose` widgets (single
highlight bar, no borders) painted on top of the PNG, with margins
set via `console --left/--right/--top/--bottom` to keep the text off
the logo. Not GRUB, not syslinux — UEFI iVentoy uses iPXE's
`snponly.efi` / `pxeboot.efi`, and `--picture` does work under UEFI
GOP despite older folklore.
Do not conflate this with Ventoy-USB, which is a separate codebase and
uses GRUB2 themes (`theme.txt`, `background_ventoy.png`, `select_c.png`).
## Rust ingredients to replicate / surpass
Most of these already exist in the workspace.
1. **Compositor (extend, don't replace)** — extend
`crates/iso-store/src/pxe_logo.rs` to emit per-resolution PNGs
(1024x768, 1280x1024, 1920x1080 as the v1 set). `image` +
`imageproc` crates handle scaling; `ab_glyph` / `fontdue` for raster
text (subtitle, hostname, version). One source SVG/logo, three to
five rendered PNGs cached on disk.
2. **Script generator**`ipxe_script.rs` already emits
`console --picture … || console`. Add a `?res=` query param (or
per-MAC client hint persisted in `hosts.json`) and serve the matching
PNG plus matching `console --x --y` line. Keep the text-console
fallback already in place.
3. **Resolution selection** — iPXE exposes `${vesa-x}` / `${vesa-y}` on
BIOS; UEFI side we can probe firmware vars at chain-time. The simpler
v1 is a "low-res / hi-res" toggle in Settings plus a per-host
override — mirrors iVentoy's UX, no kernel helper needed. True EDID
parsing is overkill for the first cut.
4. **Chrome upgrades over iVentoy** — iPXE menus are limited (single
highlight, no borders). To look distinctly cooler without leaving
iPXE: paint border / title / footer **into the PNG**, leave a window
in the middle, then `console --left/--right/--top/--bottom` to inset
the iPXE menu exactly into that window. ASCII box-drawing inside the
menu remains fragile (iPXE mangles non-ASCII on some builds — already
noted in `ipxe_script.rs`).
## Recommended architecture for the next OpenPXE release
- Build a `pxe_theme` module beside `pxe_logo.rs`: takes operator logo
+ theme tokens (accent colour, title, footer) and renders a layered
PNG (background gradient → framing chrome → logo → title bar → footer
with `${hostname}` / `${version}` / `${ip}`) at the three target
resolutions. Cache by hash of inputs.
- Serve at `/branding/pxe-menu-{w}x{h}.png`. Default 1024x768; expose a
Settings dropdown.
- In `ipxe_script.rs`, emit
`console --picture …/pxe-menu-1024x768.png --left 80 --right 80 --top 180 --bottom 60 || console`,
then the existing `menu` / `item` / `choose` block — text now lands
inside the framed window.
- Compile iPXE with `CONSOLE_FRAMEBUFFER`, `IMAGE_PNG`, `CONSOLE_CMD`,
`CONSOLE_VESAFB` (BIOS) and `CONSOLE_EFIFB` (UEFI). The v0.4.61 image
attempted this in-Docker via QEMU emulation and hit `cc1` segfaults.
The follow-up will use a Gitea Actions runner pinned to native
`linux/amd64` (an Unraid host already exists for this).
- Stretch goal: a second "theme pack" that ships a layered PNG with
subtle scanlines / grid — iPXE can't animate, but a well-designed
static composite beats iVentoy's plain centered logo handily.
## Source URLs
- https://github.com/ventoy/PXE
- https://github.com/ventoy/PXE/tree/master/iPXE
- https://github.com/ventoy/PXE/issues/11 — 1024x768 default
- https://github.com/ventoy/PXE/issues/59 — iVentoy iPXE EFI loader
- https://ipxe.org/cmd/console — `--picture` and compile flags
- https://github.com/ipxe/ipxe/discussions/945 — background image how-to
- https://github.com/ipxe/ipxe/discussions/802 — `CONSOLE_FRAMEBUFFER`
requirement
- https://github.com/ipxe/ipxe/discussions/1006 — picture resolution
behaviour
- https://www.iventoy.com/en/doc_edition.html — background / title not
user-customisable
- https://kingtam.win/archives/iventoy.html — third-party iPXE-based
iVentoy alternative