Compare commits

...
9 Commits
Author SHA1 Message Date
Miles WardandClaude Opus 4.7 900b65b3ec v0.4.65: swap kernel-mount NFS for userspace SMB (smbclient)
v0.4.64's NFS path didn't work on Unraid even with --privileged
because Unraid's base kernel ships without the nfs/nfsv4 client
modules — and no container-side configuration can load a host kernel
module. SMB has the same kernel-mount problem (`mount -t cifs` needs
the cifs module) but it also has a usable *userspace* client: Samba's
`smbclient` CLI, which speaks the SMB protocol over a plain TCP socket
with no kernel involvement. This is the same approach Bootimus uses,
and works in every container regardless of host kernel modules or
container capabilities.

What's gone:

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

What's new:

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

Why subprocess and not a Rust crate:

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

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

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

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

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

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

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

What's new:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-25 22:37:20 -04:00
27 changed files with 4644 additions and 895 deletions
Generated
+189 -8
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -84,6 +90,12 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "axum"
version = "0.7.9"
@@ -212,12 +224,24 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -280,6 +304,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -295,6 +325,15 @@ dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -406,6 +445,25 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -549,6 +607,16 @@ dependencies = [
"wasip3",
]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "globset"
version = "0.4.18"
@@ -821,6 +889,34 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"gif",
"image-webp",
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -958,6 +1054,16 @@ dependencies = [
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.0"
@@ -969,6 +1075,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "multer"
version = "3.1.0"
@@ -1001,6 +1117,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -1015,7 +1140,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openpxe"
version = "0.4.4"
version = "0.4.65"
dependencies = [
"anyhow",
"axum",
@@ -1037,9 +1162,10 @@ dependencies = [
[[package]]
name = "openpxe-core"
version = "0.4.4"
version = "0.4.65"
dependencies = [
"anyhow",
"bcrypt",
"parking_lot",
"serde",
"serde_json",
@@ -1055,7 +1181,7 @@ dependencies = [
[[package]]
name = "openpxe-dhcp-proxy"
version = "0.4.4"
version = "0.4.65"
dependencies = [
"anyhow",
"bytes",
@@ -1069,19 +1195,21 @@ dependencies = [
[[package]]
name = "openpxe-http-api"
version = "0.4.4"
version = "0.4.65"
dependencies = [
"anyhow",
"axum",
"bytes",
"futures",
"hyper",
"image",
"mime",
"mime_guess",
"openpxe-core",
"openpxe-ipxe-assets",
"openpxe-iso-store",
"openpxe-webui",
"parking_lot",
"serde",
"serde_json",
"tempfile",
@@ -1098,7 +1226,7 @@ dependencies = [
[[package]]
name = "openpxe-ipxe-assets"
version = "0.4.4"
version = "0.4.65"
dependencies = [
"openpxe-core",
"rust-embed",
@@ -1108,12 +1236,13 @@ dependencies = [
[[package]]
name = "openpxe-iso-store"
version = "0.4.4"
version = "0.4.65"
dependencies = [
"anyhow",
"bcrypt",
"bytes",
"hex",
"image",
"libc",
"openpxe-core",
"parking_lot",
@@ -1131,7 +1260,7 @@ dependencies = [
[[package]]
name = "openpxe-tftp"
version = "0.4.4"
version = "0.4.65"
dependencies = [
"anyhow",
"bytes",
@@ -1145,7 +1274,7 @@ dependencies = [
[[package]]
name = "openpxe-webui"
version = "0.4.4"
version = "0.4.65"
[[package]]
name = "parking_lot"
@@ -1182,6 +1311,19 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -1225,6 +1367,18 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quote"
version = "1.0.45"
@@ -1482,6 +1636,12 @@ dependencies = [
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "slab"
version = "0.4.12"
@@ -2122,6 +2282,12 @@ dependencies = [
"semver",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -2436,3 +2602,18 @@ name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+1 -1
View File
@@ -12,7 +12,7 @@ members = [
]
[workspace.package]
version = "0.4.4"
version = "0.4.65"
edition = "2021"
rust-version = "1.95"
license = "MIT OR Apache-2.0"
+3
View File
@@ -21,6 +21,9 @@ time.workspace = true
uuid.workspace = true
parking_lot.workspace = true
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]
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());
}
}
+4
View File
@@ -3,6 +3,7 @@
#![forbid(unsafe_code)]
pub mod arch;
pub mod auth;
pub mod boot_log;
pub mod branding;
pub mod client;
@@ -13,11 +14,14 @@ pub mod log_bus;
pub mod metrics;
pub mod queue;
pub mod settings;
pub mod sso;
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 sso::{SsoConfig, SsoStore};
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
pub use error::{Error, Result};
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(_))));
}
}
+5
View File
@@ -32,6 +32,8 @@ futures.workspace = true
mime.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]
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] }
@@ -39,3 +41,6 @@ tower = { workspace = true }
tempfile = "3.12"
serde_json = { 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"] }
+306 -47
View File
@@ -13,6 +13,7 @@
//! | `/iso/<id>/*` | Files inside the ISO (for wimboot & kernel/initrd) |
//! | `/api/*` | JSON/HTML API for the web UI |
use crate::auth as auth_api;
use crate::ipxe_script::{
render_entry, render_family_menu, render_local_hdd, render_menu, render_nic_info,
render_queue_entry, render_shell, render_tools_menu, render_util,
@@ -30,10 +31,11 @@ use axum::{
Json, Router,
};
use openpxe_core::{
ext_for_mime, BootEvent, ClientEvent, Error, Settings, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
ext_for_mime, BootEvent, ClientEvent, Error, Settings, SsoConfig, ALLOWED_LOGO_MIMES,
MAX_LOGO_BYTES,
};
use openpxe_ipxe_assets::asset_bytes;
use openpxe_iso_store::{IsoCategory, IsoMeta, NfsAddRequest};
use openpxe_iso_store::{IsoCategory, IsoMeta, IsoSource, SmbAddRequest};
use serde::Deserialize;
use serde_json::json;
use std::net::SocketAddr;
@@ -49,6 +51,13 @@ pub fn build_router(state: AppState) -> Router {
.route("/assets/app.css", get(ui_css))
.route("/assets/logo.svg", get(ui_logo))
.route("/assets/loader.svg", get(ui_loader))
// v0.4.6: PXE menu logo — the raster form of the operator's
// uploaded mark, served so iPXE's `console --picture` can
// overlay it on the boot menu. SVG uploads 404 here (iPXE
// can't rasterize SVG); we deliberately don't bundle a
// pre-rendered PNG fallback because iPXE's ASCII wordmark
// banner already provides the always-visible branding.
.route("/branding/pxe-logo", get(ui_pxe_logo))
// iPXE script endpoints.
.route("/boot.ipxe", get(boot_top_menu))
.route("/boot/:filename", get(boot_sub))
@@ -97,6 +106,24 @@ pub fn build_router(state: AppState) -> Router {
// under the Settings tab — operators chasing an integration get
// it in-product instead of having to fetch the OpenAPI YAML.
.route("/api/docs", get(api_docs))
// v0.4.5: Sonarr/Radarr-style admin Forms auth. First-run
// /setup creates the single admin account; /login validates;
// /logout revokes the session; /me powers the front-end's
// "should I show the setup page, the login page, or the
// dashboard?" decision. /me/credentials rotates the admin's
// username/password.
.route("/api/setup", post(auth_api::api_setup))
.route("/api/login", post(auth_api::api_login))
.route("/api/logout", post(auth_api::api_logout))
.route("/api/me", get(auth_api::api_me))
.route(
"/api/me/credentials",
put(auth_api::api_update_credentials),
)
// v0.4.5: SAML SSO configuration (FleetDM-shaped, storage-only).
// The actual sign-in flow lands in a later release; this just
// gives operators a place to paste their IdP metadata today.
.route("/api/sso", get(api_sso_get).put(api_sso_put))
.route("/api/clients", get(api_list_clients))
.route("/api/status", get(api_status))
.route("/api/settings", get(api_get_settings).put(api_put_settings))
@@ -105,10 +132,15 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/queue/poll/:entry_id", get(api_queue_poll))
.route("/api/queue/assign", post(api_queue_assign))
.route("/api/queue/:entry_id", delete(api_queue_release))
// Phase 4: NFS share manager.
.route("/api/nfs", get(api_nfs_list).post(api_nfs_add))
.route("/api/nfs/:id", delete(api_nfs_remove))
.route("/api/nfs/:id/scan", post(api_nfs_scan))
// v0.4.65: SMB share manager (userspace via smbclient). The
// kernel-mount NFS routes that v0.4.64 shipped are gone — they
// didn't work on hosts whose kernel lacked the nfs client
// modules (Unraid), and no container-side configuration could
// load a host kernel module. `smbclient` speaks SMB over a
// plain TCP socket in userspace, works in every container.
.route("/api/smb-shares", get(api_smb_shares_list).post(api_smb_shares_add))
.route("/api/smb-shares/:id", delete(api_smb_shares_remove))
.route("/api/smb-shares/:id/scan", post(api_smb_shares_scan))
// Phase 4: Network info (read-only) + DNS edit.
.route("/api/network", get(api_network).put(api_network_put))
// Phase 4: live-log stream + recent buffer for the Terminal tab.
@@ -128,32 +160,85 @@ pub fn build_router(state: AppState) -> Router {
// format. No auth — the metrics surface is intentionally
// boring (counts, no payloads).
.route("/metrics", get(api_metrics))
// v0.4.5: Forms-auth middleware. Layered *after* `.route(...)`
// calls so it applies uniformly; passes everything through when
// no admin is configured (tests + fresh installs ride this path).
// The allowlist inside `auth_api::require_auth` keeps PXE-essential
// endpoints reachable for iPXE clients that can't authenticate.
.layer(axum::middleware::from_fn_with_state(
state.clone(),
auth_api::require_auth,
))
.layer(TraceLayer::new_for_http())
// 16 GiB upload cap — ISOs are big; chunks stream so this isn't memory use.
.layer(DefaultBodyLimit::max(16 * 1024 * 1024 * 1024))
.with_state(state)
}
// ─── SSO config endpoints ─────────────────────────────────────────────────
async fn api_sso_get(State(state): State<AppState>) -> Json<SsoConfig> {
// We deliberately do not redact the metadata — the operator who's
// signed in needs to be able to round-trip it. /api/sso requires
// the auth middleware anyway, so unauthenticated callers can't see
// it once admin is configured.
Json(state.sso.snapshot())
}
async fn api_sso_put(State(state): State<AppState>, Json(body): Json<SsoConfig>) -> Response {
match state.sso.replace(body) {
Ok(cfg) => (StatusCode::OK, Json(cfg)).into_response(),
Err(Error::Invalid(msg)) => {
(StatusCode::BAD_REQUEST, msg).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
}
// ─── UI ────────────────────────────────────────────────────────────────────
async fn index(State(state): State<AppState>) -> Response {
let html = openpxe_webui::index_html(&state.public_base_url);
// The asset version pin in index.html (`?v=…`) is what makes
// browsers re-fetch JS/CSS after an upgrade. We use the OpenPXE
// binary version — every release ships a new value, every release
// forces a fresh URL on each asset.
let html = openpxe_webui::index_html(&state.public_base_url, env!("CARGO_PKG_VERSION"));
(
[
(
[(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
)],
),
// index.html itself must never be cached — that's how the
// browser learns about a new `?v=…` value for the assets.
(
header::CACHE_CONTROL,
HeaderValue::from_static("no-cache, must-revalidate"),
),
],
html,
)
.into_response()
}
/// Cache-Control header value used for the bundled JS/CSS/SVG assets.
/// We pin a 1-day TTL so a long-lived deployment doesn't re-fetch the
/// same bytes on every page-load, but require revalidation — combined
/// with the `?v=<version>` query string in index.html, the practical
/// upper bound on caching across an upgrade is "until the operator
/// reloads".
const ASSET_CACHE_CONTROL: HeaderValue =
HeaderValue::from_static("no-cache, must-revalidate");
async fn ui_js() -> Response {
(
[(
[
(
header::CONTENT_TYPE,
HeaderValue::from_static("application/javascript"),
)],
),
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
],
openpxe_webui::app_js(),
)
.into_response()
@@ -161,7 +246,10 @@ async fn ui_js() -> Response {
async fn ui_css() -> Response {
(
[(header::CONTENT_TYPE, HeaderValue::from_static("text/css"))],
[
(header::CONTENT_TYPE, HeaderValue::from_static("text/css")),
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
],
openpxe_webui::app_css(),
)
.into_response()
@@ -205,21 +293,101 @@ async fn ui_logo(State(state): State<AppState>) -> Response {
}
}
(
[(
[
(
header::CONTENT_TYPE,
HeaderValue::from_static("image/svg+xml"),
)],
),
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
],
openpxe_webui::logo_svg(),
)
.into_response()
}
/// v0.4.61: PXE menu logo composed for the iPXE `console --picture`
/// call. The operator can upload any raster image (PNG / JPEG / WebP /
/// GIF) of any aspect ratio; this handler decodes it, draws it
/// centered-top onto a fixed 1024×768 canvas, and returns PNG bytes.
/// That gives the same look as iVentoy regardless of what the operator
/// uploaded — a portrait logo, a wide wordmark, a square monogram all
/// land in the same place on the boot screen.
///
/// SVG uploads still 404 here — iPXE can't rasterize SVG, and rather
/// than haul in `resvg` we ask the operator to provide a raster when
/// they want a custom PXE-side logo. (The WebUI keeps using the SVG.)
async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
let Some(path) = state.branding.logo_path() else {
return (StatusCode::NOT_FOUND, "no custom logo configured").into_response();
};
let Some(mime) = state.branding.logo_mime() else {
return (StatusCode::NOT_FOUND, "no mime recorded").into_response();
};
if mime == "image/svg+xml" {
return (
StatusCode::NOT_FOUND,
"operator-uploaded logo is SVG; PXE menu requires a raster (PNG / JPEG / WebP / GIF)",
)
.into_response();
}
let bytes = match tokio::fs::read(&path).await {
Ok(b) => b,
Err(e) => {
return (
StatusCode::NOT_FOUND,
format!("custom logo unreadable: {e}"),
)
.into_response();
}
};
// Compose to a fixed 1024×768 PNG so the PXE menu paints the logo
// centered-top regardless of the operator's source dimensions. The
// `image` crate is pure-Rust + sync; offload to a blocking task
// because Lanczos resampling on a 4K source can take tens of
// milliseconds and we don't want to block the executor.
let composed =
match tokio::task::spawn_blocking(move || openpxe_iso_store::pxe_logo::compose_pxe_logo(&bytes))
.await
{
Ok(Ok(png)) => png,
Ok(Err(e)) => {
tracing::warn!(
target: "openpxe::http::branding",
error = %e, "failed to compose PXE logo PNG"
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to compose PXE logo: {e}"),
)
.into_response();
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("pxe-logo task failed: {e}"),
)
.into_response();
}
};
(
[
(header::CONTENT_TYPE, HeaderValue::from_static("image/png")),
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
],
composed,
)
.into_response()
}
async fn ui_loader() -> Response {
(
[(
[
(
header::CONTENT_TYPE,
HeaderValue::from_static("image/svg+xml"),
)],
),
(header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
],
openpxe_webui::loader_svg(),
)
.into_response()
@@ -475,12 +643,59 @@ async fn iso_raw(
headers: HeaderMap,
) -> Response {
let id = filename.strip_suffix(".iso").unwrap_or(&filename);
// v0.4.65: SMB-sourced ISOs have no on-disk path — they're
// streamed live from the remote share via `smbclient`. We look
// up the meta first to decide whether to take the path-based
// local route or the subprocess-based SMB route.
let Some(meta) = state.iso_store.get(id) else {
return (StatusCode::NOT_FOUND, "no such iso").into_response();
};
match &meta.source {
IsoSource::Local => {
let Some(path) = state.iso_store.iso_path_for(id) else {
return (StatusCode::NOT_FOUND, "no such iso").into_response();
};
match stream_file_range(&path, headers.get(header::RANGE)).await {
Ok(r) => r,
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
Err(e) => {
(StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response()
}
}
}
IsoSource::Smb {
share_id,
relative_path,
} => {
// Range requests aren't supported for SMB sources in
// v0.4.65 — smbclient's CLI can't seek mid-stream. iPXE
// chain loading and ISO sanboot do whole-file sequential
// reads, so this works in practice. A 416 here lets the
// client fall back to a full GET if it tried a range.
if headers.get(header::RANGE).is_some() {
return Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{}", meta.size_bytes))
.body(Body::empty())
.unwrap();
}
match state.smb_shares.stream_iso(share_id, relative_path).await {
Ok(stream) => {
let reader = stream.stdout;
let body_stream = tokio_util::io::ReaderStream::new(reader);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(header::CONTENT_LENGTH, meta.size_bytes)
// Tell intermediaries we don't support
// ranges on this resource; saves them from
// even trying.
.header(header::ACCEPT_RANGES, "none")
.body(Body::from_stream(body_stream))
.unwrap()
}
Err(e) => (StatusCode::BAD_GATEWAY, format!("smb stream: {e}")).into_response(),
}
}
}
}
@@ -488,6 +703,10 @@ async fn iso_file(
State(state): State<AppState>,
AxumPath((id, path)): AxumPath<(String, String)>,
) -> Response {
// In-ISO file extraction is only supported for local ISOs — it
// needs random-access reads into the ISO9660 directory tree, which
// smbclient's whole-file streaming can't do efficiently. SMB-
// sourced ISOs use the raw streaming endpoint above instead.
let Some(iso_path) = state.iso_store.iso_path_for(&id) else {
return (StatusCode::NOT_FOUND, "no such iso").into_response();
};
@@ -864,16 +1083,16 @@ async fn api_docs() -> Json<serde_json::Value> {
],
},
{
"name": "NFS shares",
"name": "SMB shares",
"endpoints": [
{"method": "GET", "path": "/api/nfs",
"summary": "List configured NFS shares with mount state and iso counts."},
{"method": "POST", "path": "/api/nfs",
"summary": "Mount an NFS share. Body: { server, export, version, read_only }."},
{"method": "DELETE", "path": "/api/nfs/:id",
"summary": "Unmount a share and drop its entries from the ISO store."},
{"method": "POST", "path": "/api/nfs/:id/scan",
"summary": "Re-walk a mounted share for ISOs."},
{"method": "GET", "path": "/api/smb-shares",
"summary": "List configured SMB shares with connection state and iso counts."},
{"method": "POST", "path": "/api/smb-shares",
"summary": "Register an SMB share. Body: { server, share, guest, username?, password?, port? }."},
{"method": "DELETE", "path": "/api/smb-shares/:id",
"summary": "Forget a share and drop its entries from the ISO store."},
{"method": "POST", "path": "/api/smb-shares/:id/scan",
"summary": "Re-list a share for new ISOs."},
],
},
{
@@ -896,10 +1115,31 @@ async fn api_docs() -> Json<serde_json::Value> {
"summary": "Upload a custom WebUI logo (multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB)."},
{"method": "DELETE", "path": "/api/branding/logo",
"summary": "Remove the custom logo and revert to the bundled mark."},
{"method": "GET", "path": "/branding/pxe-logo",
"summary": "Raster form of the operator's logo for the iPXE menu's `console --picture`. SVG uploads 404 here."},
{"method": "GET", "path": "/api/sso",
"summary": "Current SAML SSO configuration."},
{"method": "PUT", "path": "/api/sso",
"summary": "Replace SAML SSO configuration. Body: { enabled, idp_name, metadata, metadata_url }."},
{"method": "GET", "path": "/api/docs",
"summary": "This API reference."},
],
},
{
"name": "Auth (Forms)",
"endpoints": [
{"method": "POST", "path": "/api/setup",
"summary": "First-run admin bootstrap. Body: { username, password }. Refuses after the admin exists."},
{"method": "POST", "path": "/api/login",
"summary": "Sign in. Body: { username, password }. Sets the openpxe_session cookie."},
{"method": "POST", "path": "/api/logout",
"summary": "Revoke the current session and clear the cookie."},
{"method": "GET", "path": "/api/me",
"summary": "Auth status — { setup_required, authenticated, user }. Always 200."},
{"method": "PUT", "path": "/api/me/credentials",
"summary": "Rotate the admin's credentials. Body: { current_password, new_username?, new_password? }. Revokes all other sessions on success."},
],
},
{
"name": "Storage telemetry",
"endpoints": [
@@ -1269,8 +1509,11 @@ async fn api_list_clients(State(state): State<AppState>) -> Json<serde_json::Val
async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
let smb = state.smb.as_ref().map(|s| s.snapshot());
let nfs = state.nfs.list();
let nfs_active = nfs.iter().filter(|m| m.mounted).count();
// v0.4.65: NFS replaced with SMB shares. The dashboard metric
// shape stays similar (count + reachable) so the UI doesn't have
// to change its top-line tiles.
let smb_shares = state.smb_shares.list();
let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count();
let isos = state.iso_store.list();
let clients = state.clients.list();
let queue_entries = state.queue.list();
@@ -1289,7 +1532,7 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
state
.metrics
.set_queue_counts(queue_entries.len() as u64, imaging as u64);
state.metrics.set_nfs_active(nfs_active as u64);
state.metrics.set_nfs_active(smb_reachable as u64);
state.metrics.record_http(openpxe_core::HttpRoute::Api);
let now = time::OffsetDateTime::now_utc();
let uptime_secs = (now - state.started_at).whole_seconds().max(0);
@@ -1304,8 +1547,12 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
"ipxe_assets": openpxe_ipxe_assets::list_assets(),
"settings": state.settings.snapshot(),
"smb": smb,
"nfs_count": nfs.len(),
"nfs_active": nfs_active,
// Keep the field names for now so existing UI bindings on
// `iso_count2` / `client_count2` / sidebar counters keep
// working. They cover "external storage shares" generically;
// v0.4.65 the source is SMB instead of NFS.
"smb_share_count": smb_shares.len(),
"smb_share_reachable": smb_reachable,
"host_bindings": state.hosts.len(),
"custom_logo": state.branding.has_logo(),
"uptime_secs": uptime_secs,
@@ -1520,32 +1767,42 @@ async fn api_queue_release(
}
}
// ─── NFS share API ─────────────────────────────────────────────────────────
// ─── SMB share API (v0.4.65) ───────────────────────────────────────────────
//
// Replaces the NFS share manager from v0.4.64. The wire shape is similar
// — a {shares: [...]} list, a POST that returns either the share or a
// structured {error, stderr, hint} body — so the UI can render both the
// same way.
async fn api_nfs_list(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!({ "mounts": state.nfs.list() }))
async fn api_smb_shares_list(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!({ "shares": state.smb_shares.list() }))
}
async fn api_nfs_add(State(state): State<AppState>, Json(req): Json<NfsAddRequest>) -> Response {
match state.nfs.add(req).await {
Ok(m) => (StatusCode::CREATED, Json(m)).into_response(),
// Anything from the manager surfaces as a user-fixable validation
// error — bad host, kernel without NFS support, missing
// `mount.nfs`, dead server. We pass the message through verbatim
// so the UI can show it to the operator.
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
async fn api_smb_shares_add(
State(state): State<AppState>,
Json(req): Json<SmbAddRequest>,
) -> Response {
match state.smb_shares.add(req).await {
Ok(s) => (StatusCode::CREATED, Json(s)).into_response(),
Err(err) => (StatusCode::BAD_REQUEST, Json(err)).into_response(),
}
}
async fn api_nfs_remove(State(state): State<AppState>, AxumPath(id): AxumPath<String>) -> Response {
match state.nfs.remove(&id).await {
async fn api_smb_shares_remove(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Response {
match state.smb_shares.remove(&id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
}
async fn api_nfs_scan(State(state): State<AppState>, AxumPath(id): AxumPath<String>) -> Response {
match state.nfs.rescan(&id).await {
async fn api_smb_shares_scan(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> Response {
match state.smb_shares.rescan(&id).await {
Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(),
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
}
@@ -1665,9 +1922,11 @@ async fn api_metrics(State(state): State<AppState>) -> Response {
state
.metrics
.set_queue_counts(queue_entries.len() as u64, imaging as u64);
// v0.4.65: gauge tracks reachable external-storage shares. With
// NFS removed it now reflects SMB share reachability instead.
state
.metrics
.set_nfs_active(state.nfs.list().iter().filter(|m| m.mounted).count() as u64);
.set_nfs_active(state.smb_shares.list().iter().filter(|m| m.reachable).count() as u64);
let now = time::OffsetDateTime::now_utc();
let uptime = (now - state.started_at).whole_seconds().max(0) as u64;
+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());
}
}
+101
View File
@@ -29,6 +29,15 @@ use std::fmt::Write as _;
/// Top-level OpenPXE boot menu. Serialized identically for BIOS and UEFI
/// clients because iPXE normalises the menu primitives across firmwares.
///
/// v0.4.6: rendered with an iVentoy-style polished frame — centered
/// OpenPXE wordmark banner at the top (ASCII so every iPXE build can
/// paint it), a footer carrying version + arch + firmware kind, and an
/// optional `console --picture` directive that paints the operator's
/// uploaded raster logo on top when the iPXE binary on the wire was
/// built with PNG support. The ASCII banner is always rendered so
/// even when the picture call no-ops the screen still reads as
/// "OpenPXE — here is the menu" rather than a featureless box.
#[must_use]
pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> String {
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 esc:hex 1b");
let _ = writeln!(s, "set cls ${{esc:string}}[2J");
// v0.4.6: best-effort graphics console with the operator-uploaded
// raster logo. Falls back to plain text console on iPXE builds
// without PNG support — the `||` chain keeps a parse-clean
// single-statement form so even the strictest iPXE parsers accept
// it. The `console` reset at the end re-syncs the menu output.
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 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!(
s,
"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 --gap");
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) {
let _ = writeln!(s, "choose --default {default_item} target || goto menu");
@@ -575,6 +629,53 @@ mod password_tests {
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]
fn generated_scripts_do_not_emit_bare_or_trailing_fallbacks() {
let settings = Settings::default();
+1
View File
@@ -14,6 +14,7 @@
#![forbid(unsafe_code)]
pub mod app;
pub mod auth;
pub mod ipxe_script;
pub mod iso_fs;
pub mod log_stream;
+23 -8
View File
@@ -1,9 +1,10 @@
use crate::uploads::UploadSessions;
use crate::auth::SessionStore;
use openpxe_core::{
BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics,
SettingsStore,
AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus,
Metrics, SettingsStore, SsoStore,
};
use openpxe_iso_store::{IsoStore, NfsManager, SmbManager};
use openpxe_iso_store::{IsoStore, SmbManager, SmbShareManager};
use std::sync::Arc;
use time::OffsetDateTime;
@@ -25,6 +26,17 @@ pub struct AppState {
/// 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
/// text format. Cheap to clone (handles to atomics).
pub metrics: Metrics,
@@ -32,11 +44,14 @@ pub struct AppState {
/// `smb_dir` at startup; `None` in pure-Linux-only deployments where
/// Windows support is not wired in. Settings toggle drives start/stop.
pub smb: Option<Arc<SmbManager>>,
/// NFS share manager. Always present (mounting is opt-in by the
/// operator from the Storage tab); `add()` requires `mount.nfs` to be
/// available in the runtime image. Surfaces errors per-mount rather
/// than failing the global state.
pub nfs: NfsManager,
/// v0.4.65: SMB share manager — userspace consumer of remote SMB
/// shares via Samba's `smbclient` CLI. Replaces the kernel-mount
/// NFS path that v0.4.64 shipped; that path didn't work on hosts
/// (Unraid, etc.) whose kernel ships without the nfs/cifs client
/// modules, and no container-side configuration could fix it.
/// `smbclient` does the SMB protocol over a plain TCP socket in
/// userspace — works in any container, no special caps required.
pub smb_shares: SmbShareManager,
/// 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.
+90 -63
View File
@@ -88,7 +88,11 @@ async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
"isos" | "images" => Ok(isos_text(state)),
"clients" => Ok(clients_text(state)),
"queue" => queue_command(state, tail).await,
"nfs" => nfs_command(state, tail).await,
// v0.4.65: `nfs` is gone — replaced with userspace SMB share
// consumer. `smb` still controls the outbound Samba server
// for Windows install media; `share` lists/manages remote SMB
// shares OpenPXE pulls ISOs from.
"share" | "smb-share" => smb_share_command(state, tail).await,
"smb" => smb_command(state, tail).await,
"log" => log_command(state, tail),
"whoami" => Ok("operator".to_string()),
@@ -107,18 +111,18 @@ fn status_text(s: &AppState) -> String {
let clients = s.clients.list();
let queue_entries = s.queue.list();
let smb = s.smb.as_ref().map(|m| m.snapshot());
let nfs = s.nfs.list();
let nfs_active = nfs.iter().filter(|m| m.mounted).count();
let smb_shares = s.smb_shares.list();
let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count();
format!(
"OpenPXE {ver}\n\
base url: {base}\n\
interface: {nic}\n\
uptime: {up}\n\
isos: {n_isos} (local: {n_local}, nfs: {n_nfs})\n\
isos: {n_isos} (local: {n_local}, smb: {n_smb})\n\
clients: {n_clients}\n\
queue: {n_entries}\n\
smb: {smb}\n\
nfs mounts: {n_total} configured ({n_active} active)\n",
smb server: {smb}\n\
smb shares: {n_total} configured ({n_active} reachable)\n",
ver = env!("CARGO_PKG_VERSION"),
base = s.public_base_url,
nic = if s.nic_name.is_empty() {
@@ -132,15 +136,15 @@ fn status_text(s: &AppState) -> String {
.iter()
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Local))
.count(),
n_nfs = isos
n_smb = isos
.iter()
.filter(|i| !matches!(i.source, openpxe_iso_store::IsoSource::Local))
.filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Smb { .. }))
.count(),
n_clients = clients.len(),
n_entries = queue_entries.len(),
smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")),
n_total = nfs.len(),
n_active = nfs_active,
n_total = smb_shares.len(),
n_active = smb_reachable,
)
}
@@ -158,7 +162,8 @@ fn isos_text(s: &AppState) -> String {
for i in isos {
let src = match i.source {
openpxe_iso_store::IsoSource::Local => "local".to_string(),
openpxe_iso_store::IsoSource::Nfs { mount_id, .. } => format!("nfs:{mount_id}"),
// v0.4.65: SMB userspace consumer replaced kernel-mount NFS.
openpxe_iso_store::IsoSource::Smb { share_id, .. } => format!("smb:{share_id}"),
};
let _ = writeln!(
out,
@@ -264,89 +269,111 @@ async fn queue_command(s: &AppState, args: &[String]) -> Result<String, String>
}
}
// ── nfs ────────────────────────────────────────────────────────────────
// ── share (v0.4.65: SMB shares) ─────────────────────────────────────────
async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
async fn smb_share_command(s: &AppState, args: &[String]) -> Result<String, String> {
match args.first().map(String::as_str) {
None | Some("list") => {
let mounts = s.nfs.list();
if mounts.is_empty() {
return Ok("(no NFS mounts configured)".into());
let shares = s.smb_shares.list();
if shares.is_empty() {
return Ok("(no SMB shares configured)".into());
}
let mut out = String::new();
let _ = writeln!(
out,
"{:<24} {:<6} {:<7} {:<6} TARGET",
"ID", "VER", "STATUS", "ISOS"
"{:<24} {:<7} {:<6} {:<6} TARGET",
"ID", "STATUS", "AUTH", "ISOS"
);
for m in mounts {
let status = if m.mounted { "ok" } else { "down" };
for m in shares {
let status = if m.reachable { "ok" } else { "down" };
let auth = if m.guest { "guest" } else { "user" };
let _ = writeln!(
out,
"{:<24} {:<6} {:<7} {:<6} {}:{}",
"{:<24} {:<7} {:<6} {:<6} //{}/{}",
truncate(&m.id, 24),
match m.version {
openpxe_iso_store::NfsVersion::V3 => "v3",
openpxe_iso_store::NfsVersion::V41 => "v4.1",
},
status,
auth,
m.iso_count,
m.server,
m.export,
m.share,
);
if let Some(e) = m.last_error {
let _ = writeln!(out, " error: {e}");
}
if let Some(h) = m.last_hint {
let _ = writeln!(out, " hint: {h}");
}
}
Ok(out)
}
Some("mount") => {
// nfs mount <server>:<export> [v3|v41] [ro|rw]
Some("add") => {
// share add //server/share [guest|user:password]
let target = args
.get(1)
.ok_or_else(|| "usage: nfs mount <server>:<export> [v3|v41] [ro|rw]".to_string())?;
let (server, export) = target
.split_once(':')
.ok_or_else(|| "target must be 'server:/export'".to_string())?;
let version = match args.get(2).map(String::as_str) {
Some("v3") => openpxe_iso_store::NfsVersion::V3,
Some("v41") | None => openpxe_iso_store::NfsVersion::V41,
Some(other) => {
return Err(format!("unknown nfs version: {other} (expect v3 or v41)"))
}
.ok_or_else(|| {
"usage: share add //server/share [guest|user:password]".to_string()
})?;
// Accept either `//server/share` (UNC-style) or
// `server:share` (shorter to type).
let stripped = target.trim_start_matches('/').trim_start_matches('\\');
let (server, share) = if let Some((s, p)) = stripped.split_once('/') {
(s, p)
} else if let Some((s, p)) = stripped.split_once(':') {
(s, p)
} else {
return Err("target must be '//server/share' or 'server:share'".into());
};
let read_only = !matches!(args.get(3).map(String::as_str), Some("rw"));
let req = openpxe_iso_store::NfsAddRequest {
// Auth spec: "guest" or "user:password". Default: guest.
let auth = args.get(2).cloned().unwrap_or_else(|| "guest".into());
let (guest, username, password) = if auth == "guest" {
(true, None, None)
} else if let Some((u, p)) = auth.split_once(':') {
(false, Some(u.to_string()), Some(p.to_string()))
} else {
return Err("auth must be 'guest' or 'user:password'".into());
};
let req = openpxe_iso_store::SmbAddRequest {
server: server.to_string(),
export: export.to_string(),
version,
read_only,
share: share.to_string(),
username,
password,
guest,
port: None,
};
match s.nfs.add(req).await {
Ok(m) => Ok(format!("mounted {} ({} isos)", m.id, m.iso_count)),
Err(e) => Err(format!("mount failed: {e}")),
match s.smb_shares.add(req).await {
Ok(m) => Ok(format!("added {} ({} isos)", m.id, m.iso_count)),
Err(e) => {
let mut out = format!("add failed: {}", e.error);
if let Some(h) = e.hint {
out.push_str("\nhint: ");
out.push_str(&h);
}
Err(out)
}
}
Some("unmount") => {
}
Some("remove") => {
let id = args
.get(1)
.ok_or_else(|| "usage: nfs unmount <id>".to_string())?;
match s.nfs.remove(id).await {
Ok(()) => Ok(format!("unmounted {id}")),
Err(e) => Err(format!("unmount failed: {e}")),
.ok_or_else(|| "usage: share remove <id>".to_string())?;
match s.smb_shares.remove(id).await {
Ok(()) => Ok(format!("removed {id}")),
Err(e) => Err(format!("remove failed: {e}")),
}
}
Some("scan") => {
let id = args
.get(1)
.ok_or_else(|| "usage: nfs scan <id>".to_string())?;
match s.nfs.rescan(id).await {
.ok_or_else(|| "usage: share scan <id>".to_string())?;
match s.smb_shares.rescan(id).await {
Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
Err(e) => Err(format!("scan failed: {e}")),
}
}
Some(other) => Err(format!(
"unknown nfs subcommand: {other}\ntry: nfs [list|mount|unmount|scan]"
"unknown share subcommand: {other}\ntry: share [list|add|remove|scan]"
)),
}
}
@@ -487,13 +514,13 @@ OpenPXE terminal — available commands:
queue assign-all <target> assign every waiting client
queue release <entry_id> release one queued client
nfs list list NFS mounts
nfs mount <s>:<e> [v3|v41] [ro|rw] add and mount an NFS share
nfs unmount <id> unmount and forget a share
nfs scan <id> re-scan a share for new ISOs
share list list configured SMB shares
share add //srv/share [auth] add an SMB share; auth = 'guest' or 'user:pass'
share remove <id> forget an SMB share
share scan <id> re-list a share for new ISOs
smb status SMB (Samba) state
smb start | stop | reload control smbd
smb status outbound Samba state (Windows install media)
smb start | stop | reload control the outbound smbd
log clear drop the in-memory log ring buffer
log tail [n] show the last n buffered lines (default 20)
@@ -508,10 +535,10 @@ mod tests {
#[test]
fn shell_split_basic() {
assert_eq!(shell_split(""), Vec::<String>::new());
assert_eq!(shell_split("nfs list"), vec!["nfs", "list"]);
assert_eq!(shell_split("share list"), vec!["share", "list"]);
assert_eq!(
shell_split("nfs mount 10.0.0.5:/srv v41 ro"),
vec!["nfs", "mount", "10.0.0.5:/srv", "v41", "ro"]
shell_split("share add //nas/isos guest"),
vec!["share", "add", "//nas/isos", "guest"]
);
}
+502 -14
View File
@@ -14,7 +14,7 @@ use axum::body::Body;
use axum::http::{header, Request, StatusCode};
use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore};
use openpxe_http_api::{build_router, AppState};
use openpxe_iso_store::{IsoStore, NfsManager};
use openpxe_iso_store::{IsoStore, SmbShareManager};
use tempfile::tempdir;
use tower::ServiceExt;
@@ -94,12 +94,14 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
let clients = ClientRegistry::new();
let queue = DeploymentQueue::new();
let settings = SettingsStore::load_or_default(dir.path());
let nfs = NfsManager::new(dir.path(), iso_store.clone());
iso_store.set_nfs_root(nfs.mount_root());
let smb_shares = SmbShareManager::new(dir.path(), iso_store.clone());
let log_bus = LogBus::new(64);
let hosts = HostBindings::load_or_default(dir.path());
let boot_log = openpxe_core::BootLog::load_or_default(dir.path());
let branding = openpxe_core::BrandingStore::load_or_default(dir.path());
let admin = openpxe_core::AdminStore::load_or_default(dir.path());
let sso = openpxe_core::SsoStore::load_or_default(dir.path());
let sessions = openpxe_http_api::auth::SessionStore::default();
let metrics = Metrics::new();
let state = AppState {
iso_store,
@@ -109,9 +111,12 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
hosts,
boot_log,
branding,
admin,
sessions,
sso,
metrics,
smb: None,
nfs,
smb_shares,
uploads: openpxe_http_api::uploads::UploadSessions::default(),
log_bus,
started_at: time::OffsetDateTime::now_utc(),
@@ -458,35 +463,72 @@ async fn no_external_urls_in_generated_ipxe() {
// ── Phase 4 integration tests ────────────────────────────────────────────
// v0.4.65: kernel-mount NFS replaced with userspace SMB via smbclient.
#[tokio::test]
async fn nfs_add_with_bad_export_is_rejected() {
// Validation must happen before we shell out to /bin/mount —
// otherwise the operator sees opaque kernel errors instead of a
// clear "your export must start with /" hint.
async fn smb_share_add_with_missing_server_is_rejected() {
// Validation must run before we shell out to smbclient — otherwise
// operators see opaque NT_STATUS codes for what's really a typo.
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, b) = post_json(
&app,
"/api/nfs",
r#"{"server":"10.0.0.5","export":"isos","version":"v41","read_only":true}"#,
"/api/smb-shares",
r#"{"server":"","share":"isos","guest":true}"#,
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let msg = String::from_utf8_lossy(&b);
assert!(
msg.contains("export"),
msg.to_lowercase().contains("server"),
"expected validation hint, got: {msg}"
);
}
#[tokio::test]
async fn nfs_list_starts_empty() {
async fn smb_share_add_requires_username_when_not_guest() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, b) = get(&app, "/api/nfs").await;
let (s, b) = post_json(
&app,
"/api/smb-shares",
r#"{"server":"10.0.0.5","share":"isos","guest":false}"#,
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let msg = String::from_utf8_lossy(&b);
assert!(
msg.to_lowercase().contains("username"),
"expected username hint, got: {msg}"
);
}
#[tokio::test]
async fn smb_share_add_rejects_paths_in_share_name() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, b) = post_json(
&app,
"/api/smb-shares",
r#"{"server":"10.0.0.5","share":"isos/subdir","guest":true}"#,
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let msg = String::from_utf8_lossy(&b);
assert!(
msg.to_lowercase().contains("share name"),
"expected share name hint, got: {msg}"
);
}
#[tokio::test]
async fn smb_shares_list_starts_empty() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, b) = get(&app, "/api/smb-shares").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["mounts"].as_array().unwrap().len(), 0);
assert_eq!(v["shares"].as_array().unwrap().len(), 0);
}
#[tokio::test]
@@ -1406,3 +1448,449 @@ async fn put_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode,
.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
tempfile = "3.12"
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]
tempfile = "3.12"
+8 -2
View File
@@ -18,15 +18,21 @@
pub mod entry;
pub mod introspect;
pub mod nfs;
pub mod pxe_logo;
pub mod smb;
pub mod smb_share;
pub mod store;
pub mod windows;
pub use entry::{BootEntry, BootKind, KernelArgs};
pub use introspect::{DistroFamily, IntrospectionReport};
pub use nfs::{NfsAddRequest, NfsManager, NfsMount, NfsVersion};
// v0.4.65: kernel-mount NFS is gone. SMB shares via Samba's userspace
// `smbclient` CLI replaced it — works in any container (no
// CAP_SYS_ADMIN, no host kernel modules), matching how Bootimus and
// every other PXE/imaging tool that supports network storage handles
// it.
pub use smb::{extract_windows_iso, SmbManager, SmbState};
pub use smb_share::{SmbAddRequest, SmbShare, SmbShareError, SmbShareManager, SmbStream};
pub use store::{
generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore,
UploadHandle,
-558
View File
@@ -1,558 +0,0 @@
//! NFS share manager.
//!
//! Lets an operator mount a remote NFS export as an ISO source instead of
//! uploading every ISO into the container's PVC. Supports NFSv3 and
//! NFSv4.1 — the two versions the user explicitly asked for.
//!
//! ## How it works
//!
//! 1. Operator submits a mount spec via the Storage tab:
//! `{ server: "10.0.0.20", export: "/srv/isos", version: "v41" }`.
//! 2. We slugify a stable id, mkdir `<work_dir>/nfs/<id>/`, then shell out
//! to `/bin/mount -t nfs -o vers=...,ro,nolock server:export local`.
//! 3. On success we walk the mount point looking for `*.iso` files and
//! register each one with the `IsoStore` as an external source — same
//! introspection pipeline as a web upload, but no sha256 (the bytes
//! live on a remote machine; hashing them would suck them through the
//! network on every restart).
//! 4. On failure we record `last_error` on the spec and persist anyway
//! so the UI can show a row in red rather than silently dropping it.
//!
//! ## Operational notes
//!
//! - Mounting NFS inside a container needs `CAP_SYS_ADMIN` and the
//! `nfs-common` package. The default image ships these (see Dockerfile).
//! - On OpenShift, the SCC must allow `CAP_SYS_ADMIN`. The bundled SCC
//! doesn't — operators have to opt in by switching to a more privileged
//! SCC or running NFS mounts as a CSI driver outside the pod.
//! - Mount commands are issued sequentially under a single mutex to avoid
//! `mount` racing on the same target dir.
//!
//! ## Persistence
//!
//! Mount specs (without runtime state) live at `<work_dir>/nfs.json`,
//! re-mounted on startup. Mounts that fail to come back online keep their
//! spec and their `last_error` so the operator sees what happened.
use crate::introspect::{introspect, IntrospectionReport};
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
use openpxe_core::{Error, Result};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use time::OffsetDateTime;
use tokio::process::Command;
/// Wire-protocol versions we support. Keep this enum closed — silently
/// accepting "auto" or letting the kernel negotiate would mean operators
/// could never confirm which version is in use.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NfsVersion {
/// NFSv3 — UDP/TCP, separate `mountd` protocol. Required for many
/// older NAS appliances.
V3,
/// NFSv4.1 — single TCP port (2049), session-based. Modern default.
V41,
}
impl NfsVersion {
fn vers_arg(self) -> &'static str {
match self {
Self::V3 => "vers=3",
Self::V41 => "vers=4.1",
}
}
}
/// One configured mount. The id is generated from server+export so the
/// operator can re-add the same export idempotently.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NfsMount {
pub id: String,
pub server: String,
pub export: String,
pub version: NfsVersion,
/// Read-only by default — most ISO libraries are. Operators that need
/// write can flip this off but OpenPXE itself never writes.
pub read_only: bool,
/// Local mount point under `<work_dir>/nfs/`.
pub local_path: PathBuf,
/// Whether the mount is currently active.
pub mounted: bool,
/// Last error encountered on a `mount` or `umount` attempt; cleared on
/// success.
pub last_error: Option<String>,
#[serde(with = "time::serde::rfc3339::option")]
pub last_attempt: Option<OffsetDateTime>,
/// Number of `.iso` files found on the share (re-counted on each scan).
pub iso_count: u32,
}
/// Spec submitted by the UI. Server and export are normalized before use.
#[derive(Debug, Clone, Deserialize)]
pub struct NfsAddRequest {
pub server: String,
pub export: String,
#[serde(default = "default_version")]
pub version: NfsVersion,
#[serde(default = "default_ro")]
pub read_only: bool,
}
fn default_version() -> NfsVersion {
NfsVersion::V41
}
fn default_ro() -> bool {
true
}
#[derive(Debug, Default)]
struct Inner {
mounts: HashMap<String, NfsMount>,
}
/// Manages NFS mounts and surfaces them as ISO sources.
///
/// Cheap to clone — internal state is `Arc<Mutex<...>>`.
#[derive(Debug, Clone)]
pub struct NfsManager {
work_root: Arc<PathBuf>,
state_path: Arc<PathBuf>,
inner: Arc<Mutex<Inner>>,
iso_store: IsoStore,
/// Single-writer lock around the actual `mount`/`umount` shell-outs;
/// avoids racing on the same target directory.
mount_lock: Arc<tokio::sync::Mutex<()>>,
}
impl NfsManager {
/// Construct a manager rooted at `work_dir`. Mount points live under
/// `<work_dir>/nfs/<id>/`. State persists to `<work_dir>/nfs.json`.
#[must_use]
pub fn new(work_dir: &Path, iso_store: IsoStore) -> Self {
let work_root = work_dir.join("nfs");
let state_path = work_dir.join("nfs.json");
Self {
work_root: Arc::new(work_root),
state_path: Arc::new(state_path),
inner: Arc::new(Mutex::new(Inner::default())),
iso_store,
mount_lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
/// Where this manager mounts shares. Used by `IsoStore` to resolve
/// NFS-backed `IsoMeta`s to their on-disk path.
#[must_use]
pub fn mount_root(&self) -> PathBuf {
self.work_root.as_ref().clone()
}
/// Load persisted state and re-attempt every mount. Errors are logged
/// per-mount but never fail the call — startup must not block on a
/// remote NFS server being slow.
pub async fn load_and_remount(&self) -> Result<()> {
tokio::fs::create_dir_all(self.work_root.as_path()).await?;
let mounts = match tokio::fs::read_to_string(self.state_path.as_path()).await {
Ok(text) => serde_json::from_str::<Vec<NfsMount>>(&text).unwrap_or_default(),
Err(_) => Vec::new(),
};
for mut m in mounts {
// Always start from "not mounted" — the kernel state was lost
// when the process died. We'll try to remount each one.
m.mounted = false;
m.last_error = None;
self.inner.lock().mounts.insert(m.id.clone(), m.clone());
if let Err(e) = self.try_mount(&m.id).await {
tracing::warn!(
target: "openpxe::nfs",
id = %m.id, error = %e,
"could not remount NFS share on startup"
);
}
}
Ok(())
}
/// Add a new mount. Returns the resulting `NfsMount` (with `mounted`
/// reflecting reality) or an error if the spec was invalid.
pub async fn add(&self, req: NfsAddRequest) -> Result<NfsMount> {
let server = req.server.trim().to_string();
let export = req.export.trim().to_string();
if server.is_empty() {
return Err(Error::Invalid("server is required".into()));
}
if !export.starts_with('/') {
return Err(Error::Invalid("export path must start with '/'".into()));
}
let id = mount_id(&server, &export);
let local_path = self.work_root.join(&id);
tokio::fs::create_dir_all(&local_path).await?;
let mount = NfsMount {
id: id.clone(),
server,
export,
version: req.version,
read_only: req.read_only,
local_path,
mounted: false,
last_error: None,
last_attempt: None,
iso_count: 0,
};
self.inner.lock().mounts.insert(id.clone(), mount);
self.persist_locked();
self.try_mount(&id).await?;
Ok(self.get(&id).expect("mount just inserted"))
}
/// Unmount and forget a share. Removes any ISOs it contributed from
/// the IsoStore and deletes the local mount point. Idempotent.
pub async fn remove(&self, id: &str) -> Result<()> {
// Best-effort umount; even if it fails (e.g. server unreachable)
// we still want to drop the in-memory record.
let _ = self.umount_one(id).await;
let local_path = {
let mut g = self.inner.lock();
g.mounts.remove(id).map(|m| m.local_path)
};
self.persist_locked();
self.iso_store.drop_external_source(id);
if let Some(p) = local_path {
// rmdir only — never recurse, the mount could still be live
// on some kernel error path and we don't want to nuke a
// remote filesystem.
let _ = tokio::fs::remove_dir(&p).await;
}
Ok(())
}
/// Re-scan a mounted share for ISOs, refreshing the IsoStore.
pub async fn rescan(&self, id: &str) -> Result<u32> {
let mount = self
.get(id)
.ok_or_else(|| Error::Invalid(format!("no such mount '{id}'")))?;
if !mount.mounted {
return Err(Error::Invalid(format!("mount '{id}' is not active")));
}
let count = self.scan_and_register(&mount).await?;
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
m.iso_count = count;
}
self.persist_locked();
Ok(count)
}
/// Snapshot of every configured mount.
#[must_use]
pub fn list(&self) -> Vec<NfsMount> {
let g = self.inner.lock();
let mut v: Vec<_> = g.mounts.values().cloned().collect();
v.sort_by(|a, b| a.id.cmp(&b.id));
v
}
/// Look up a single mount by id.
#[must_use]
pub fn get(&self, id: &str) -> Option<NfsMount> {
self.inner.lock().mounts.get(id).cloned()
}
// ── internals ─────────────────────────────────────────────────────
async fn try_mount(&self, id: &str) -> Result<()> {
let _g = self.mount_lock.lock().await;
let m = self
.get(id)
.ok_or_else(|| Error::Invalid(format!("no such mount '{id}'")))?;
let now = OffsetDateTime::now_utc();
// Already mounted? Skip — `mount` would error on a busy target
// and confuse the operator's UI status.
if is_mountpoint(&m.local_path).await {
self.update_status(id, true, None, now);
// Even though already mounted, we still want a fresh ISO count.
let count = self.scan_and_register(&m).await.unwrap_or(0);
self.update_iso_count(id, count);
return Ok(());
}
let opts = mount_options(&m);
let target = format!("{}:{}", m.server, m.export);
let output = Command::new("mount")
.arg("-t")
.arg("nfs")
.arg("-o")
.arg(&opts)
.arg(&target)
.arg(&m.local_path)
.output()
.await;
match output {
Ok(out) if out.status.success() => {
tracing::info!(
target: "openpxe::nfs",
id = %id, server = %m.server, export = %m.export,
version = ?m.version,
"NFS mount succeeded"
);
self.update_status(id, true, None, now);
let count = self.scan_and_register(&m).await.unwrap_or(0);
self.update_iso_count(id, count);
Ok(())
}
Ok(out) => {
let err = format!(
"mount exit {}: {}",
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stderr).trim()
);
tracing::warn!(target: "openpxe::nfs", id = %id, "{err}");
self.update_status(id, false, Some(err.clone()), now);
Err(Error::Invalid(err))
}
Err(e) => {
let err = format!("could not exec /bin/mount: {e}");
tracing::error!(target: "openpxe::nfs", id = %id, "{err}");
self.update_status(id, false, Some(err.clone()), now);
Err(Error::Invalid(err))
}
}
}
async fn umount_one(&self, id: &str) -> Result<()> {
let _g = self.mount_lock.lock().await;
let Some(m) = self.get(id) else { return Ok(()) };
if !is_mountpoint(&m.local_path).await {
self.update_status(id, false, None, OffsetDateTime::now_utc());
return Ok(());
}
// -l = lazy: detach immediately, finish when no process has a
// handle. Important if a stale ISO read is still in flight.
let out = Command::new("umount")
.arg("-l")
.arg(&m.local_path)
.output()
.await;
match out {
Ok(o) if o.status.success() => {
self.update_status(id, false, None, OffsetDateTime::now_utc());
Ok(())
}
Ok(o) => {
let e = format!(
"umount exit {}: {}",
o.status.code().unwrap_or(-1),
String::from_utf8_lossy(&o.stderr).trim()
);
self.update_status(id, false, Some(e.clone()), OffsetDateTime::now_utc());
Err(Error::Invalid(e))
}
Err(e) => {
let e = format!("could not exec /bin/umount: {e}");
self.update_status(id, false, Some(e.clone()), OffsetDateTime::now_utc());
Err(Error::Invalid(e))
}
}
}
/// Walk the mount point for `*.iso` files, introspect each one, and
/// register it with the IsoStore as an NFS-sourced entry. Returns the
/// count of ISOs registered.
async fn scan_and_register(&self, m: &NfsMount) -> Result<u32> {
// Drop any prior entries from this mount before re-registering, so
// a removed file disappears from the store.
self.iso_store.drop_external_source(&m.id);
let mut walker = tokio::fs::read_dir(&m.local_path).await?;
let mut count = 0u32;
while let Some(entry) = walker.next_entry().await? {
let p = entry.path();
if p.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
!= Some("iso")
{
continue;
}
let filename = match p.file_name().and_then(|s| s.to_str()) {
Some(f) => f.to_string(),
None => continue,
};
let size = tokio::fs::metadata(&p).await?.len();
// Introspection is sync + IO-bound (reads ISO9660 PVD). Push
// it to a blocking thread so the runtime stays responsive on
// a slow share.
let p_owned = p.clone();
let report: IntrospectionReport =
tokio::task::spawn_blocking(move || introspect(&p_owned))
.await
.map_err(|e| Error::Other(e.into()))?;
let id = format!("nfs-{}-{}", m.id, slugify_str(&filename));
let boot_entries = generate_boot_entries_for(&id, &filename, &report);
let source = IsoSource::Nfs {
mount_id: m.id.clone(),
relative_path: filename.clone(),
};
self.iso_store
.register_external(id, filename, size, report, boot_entries, source);
count += 1;
}
Ok(count)
}
fn update_status(&self, id: &str, mounted: bool, err: Option<String>, ts: OffsetDateTime) {
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
m.mounted = mounted;
m.last_error = err;
m.last_attempt = Some(ts);
}
self.persist_locked();
}
fn update_iso_count(&self, id: &str, count: u32) {
if let Some(m) = self.inner.lock().mounts.get_mut(id) {
m.iso_count = count;
}
self.persist_locked();
}
/// Atomically replace the on-disk JSON with the current state.
/// Persistence errors are logged, never propagated — settings live in
/// memory authoritatively, matching the SettingsStore policy.
fn persist_locked(&self) {
let mounts: Vec<NfsMount> = self.inner.lock().mounts.values().cloned().collect();
let path = self.state_path.as_path();
let tmp = path.with_extension("json.tmp");
let body = match serde_json::to_vec_pretty(&mounts) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::nfs", "serialize NFS state: {e}");
return;
}
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::nfs", "write NFS state tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, path) {
tracing::warn!(target: "openpxe::nfs", "rename NFS state: {e}");
}
}
}
fn mount_options(m: &NfsMount) -> String {
let mut opts = vec![m.version.vers_arg().to_string()];
if m.read_only {
opts.push("ro".into());
} else {
opts.push("rw".into());
}
// `nolock` for v3 — many storage appliances disable lockd; we don't
// need locking for read-only ISO access anyway.
if matches!(m.version, NfsVersion::V3) {
opts.push("nolock".into());
}
// Soft mount with a generous timeout — better to surface a hung share
// as a user-visible error than to wedge the iPXE client forever on a
// dead NFS server.
opts.push("soft".into());
opts.push("timeo=100".into());
opts.push("retrans=3".into());
opts.join(",")
}
fn mount_id(server: &str, export: &str) -> String {
let raw = format!("{server}{export}");
slugify_str(&raw)
}
/// Detect whether `path` is currently a mount point. We don't have
/// `is_mountpoint(2)`, so compare the parent's device id to the dir's;
/// if they differ the dir is a mount.
async fn is_mountpoint(path: &Path) -> bool {
let Some(parent) = path.parent() else {
return false;
};
let Ok(m1) = tokio::fs::metadata(path).await else {
return false;
};
let Ok(m2) = tokio::fs::metadata(parent).await else {
return false;
};
use std::os::unix::fs::MetadataExt;
m1.dev() != m2.dev()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_arg() {
assert_eq!(NfsVersion::V3.vers_arg(), "vers=3");
assert_eq!(NfsVersion::V41.vers_arg(), "vers=4.1");
}
#[test]
fn mount_options_v3_includes_nolock() {
let m = NfsMount {
id: "x".into(),
server: "s".into(),
export: "/e".into(),
version: NfsVersion::V3,
read_only: true,
local_path: PathBuf::from("/tmp/x"),
mounted: false,
last_error: None,
last_attempt: None,
iso_count: 0,
};
let opts = mount_options(&m);
assert!(opts.contains("vers=3"));
assert!(opts.contains("ro"));
assert!(opts.contains("nolock"));
assert!(opts.contains("soft"));
}
#[test]
fn mount_options_v41_no_nolock() {
let m = NfsMount {
id: "x".into(),
server: "s".into(),
export: "/e".into(),
version: NfsVersion::V41,
read_only: false,
local_path: PathBuf::from("/tmp/x"),
mounted: false,
last_error: None,
last_attempt: None,
iso_count: 0,
};
let opts = mount_options(&m);
assert!(opts.contains("vers=4.1"));
assert!(opts.contains("rw"));
assert!(!opts.contains("nolock"));
}
#[test]
fn mount_id_is_stable_and_safe() {
let a = mount_id("10.0.0.5", "/srv/isos");
let b = mount_id("10.0.0.5", "/srv/isos");
assert_eq!(a, b);
assert!(!a.contains('/'));
assert!(!a.contains('.'));
}
}
+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());
}
}
+940
View File
@@ -0,0 +1,940 @@
//! SMB share consumer — replaces the kernel-mount NFS path that v0.4.64
//! shipped.
//!
//! ## Why SMB and not NFS
//!
//! v0.4.64 tried to make `mount -t nfs` work inside the OpenPXE
//! container. With `CAP_SYS_ADMIN` + `--privileged` we still hit the
//! same `mount.nfs: failed to apply fstab options` on Unraid because
//! Unraid's base kernel ships without the `nfs` / `nfsv4` client
//! modules loaded. No amount of container-side configuration can
//! load a kernel module on the host.
//!
//! SMB has the same kernel-side problem (`mount -t cifs` needs the
//! `cifs` kernel module) but unlike NFS it has a usable **userspace**
//! client: Samba's `smbclient` CLI. It speaks the SMB protocol over a
//! plain TCP socket, no kernel modules required. Bootimus uses the
//! same approach.
//!
//! ## How it works
//!
//! 1. Operator submits a share spec via the Storage tab:
//! `{ server: "192.168.1.51", share: "isos",
//! username, password, guest }`.
//! 2. We write credentials to a 0600-permission tempfile under
//! `<work_dir>/smb_creds/`. Passing them on the command line would
//! leak them through `ps` and the container's audit log.
//! 3. We test the connection by listing the share's root with
//! `smbclient //server/share -A creds_file -c 'ls *.iso'`. If the
//! server is unreachable, the share doesn't exist, or auth fails,
//! we get a clean error before persisting anything.
//! 4. We parse the `ls` output for `*.iso` filenames and sizes, and
//! register each one with the `IsoStore` as an
//! `IsoSource::Smb { share_id, relative_path }`.
//! 5. When a PXE client requests the bytes, the HTTP handler asks this
//! manager for an async reader. We spawn
//! `smbclient //server/share -A creds_file -c 'get file -'` and
//! pipe its stdout straight into the response body. No double
//! storage, no temp files.
//!
//! ## Why subprocess and not a Rust library
//!
//! The Debian runtime image already ships the `samba` package
//! (Dockerfile line 84) — `smbclient` is right there. Library options
//! like `pavao` wrap `libsmbclient` so they still pull in the same C
//! library at runtime. Subprocess is simpler, the API surface is
//! whatever the operator can verify with `smbclient` at a shell, and
//! debugging "what does smbclient see?" is trivial.
//!
//! ## Range request limitations (v0.4.65)
//!
//! `smbclient -c 'get file -'` is a sequential whole-file stream;
//! there's no native seek in the CLI. We honor full GETs and reject
//! HTTP Range requests with `416 Range Not Satisfiable` for
//! SMB-sourced ISOs. PXE clients in practice request the whole file:
//! iPXE chain loading, casper sanboot, wimboot all do sequential
//! streaming. A follow-up release can add libsmbclient-based seek if
//! a real workload needs it.
use crate::introspect::{DistroFamily, IntrospectionReport};
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
use openpxe_core::{Error, Result};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use time::OffsetDateTime;
use tokio::process::Command;
/// Default TCP port for SMB / CIFS. The wire protocol moved to 445
/// years ago; 139 (NetBIOS) is legacy and we don't expose it as an
/// option.
const DEFAULT_SMB_PORT: u16 = 445;
/// Maximum time we wait for a TCP connection to the SMB server during
/// the pre-flight probe. Same shape as the v0.4.64 NFS probe — short
/// enough that a wrong IP doesn't make the UI hang for 30s, long
/// enough that a slow appliance can still answer.
const PROBE_TIMEOUT: Duration = Duration::from_secs(4);
/// One configured SMB share. The id is derived from server+share so an
/// operator pasting the same coordinates twice gets idempotent
/// behaviour rather than a duplicate row.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmbShare {
pub id: String,
pub server: String,
pub share: String,
/// Username used for the SMB connection. Empty when `guest` is
/// true. Stored so the UI can echo it back; the password lives in
/// the separate credentials file (see `creds_path`).
pub username: String,
/// True when we're connecting with `-N` (anonymous / guest mode).
/// Most NAS appliances that expose ISO libraries do so as
/// guest-readable; this is the common case.
pub guest: bool,
/// TCP port — 445 unless the operator overrode it. Persisted so
/// the UI can echo it back.
#[serde(default = "default_port")]
pub port: u16,
/// Most recent error encountered talking to the share, or `None`
/// on success. Cleared every successful operation.
pub last_error: Option<String>,
/// Operator-friendly translation of `last_error`. None when we
/// don't have a friendlier rendition.
pub last_hint: Option<String>,
#[serde(with = "time::serde::rfc3339::option")]
pub last_scan: Option<OffsetDateTime>,
/// Number of `*.iso` files we know about on the share as of the
/// most recent scan.
pub iso_count: u32,
/// Whether the connection's currently working. `true` after a
/// successful scan, `false` after a failure. Drives the UI dot.
pub reachable: bool,
/// Path to the credentials file on disk. Internal — not surfaced
/// in the API JSON; we serialize it for restart-survival but the
/// UI doesn't render it.
#[serde(default)]
#[serde(skip_serializing)]
pub(crate) creds_path: Option<PathBuf>,
}
/// Submission from the UI / API.
#[derive(Debug, Clone, Deserialize)]
pub struct SmbAddRequest {
pub server: String,
pub share: String,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub password: Option<String>,
#[serde(default)]
pub guest: bool,
#[serde(default)]
pub port: Option<u16>,
}
fn default_port() -> u16 {
DEFAULT_SMB_PORT
}
/// Structured error surfaced to the API and rendered in the UI as two
/// lines: the raw `error` from smbclient + an actionable `hint`.
/// Mirrors the v0.4.64 NFS error shape so the storage tab can use a
/// single rendering path.
#[derive(Debug, Clone, Serialize)]
pub struct SmbShareError {
pub error: String,
pub stderr: String,
pub hint: Option<String>,
}
impl SmbShareError {
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)]
struct Inner {
shares: HashMap<String, SmbShare>,
}
/// Manages SMB shares and surfaces their ISOs through the IsoStore.
///
/// Cheap to clone — internal state is `Arc<Mutex<...>>`.
#[derive(Debug, Clone)]
pub struct SmbShareManager {
creds_root: Arc<PathBuf>,
state_path: Arc<PathBuf>,
inner: Arc<Mutex<Inner>>,
iso_store: IsoStore,
/// Serializes scan/list/get against the same share. smbclient
/// itself is fine concurrent across processes, but bundling
/// operations through a single lock makes test ordering and log
/// output predictable.
op_lock: Arc<tokio::sync::Mutex<()>>,
}
impl SmbShareManager {
/// Construct a manager rooted at `work_dir`. Credentials files
/// live under `<work_dir>/smb_creds/` with 0600 permissions; state
/// persists to `<work_dir>/smb_shares.json`.
#[must_use]
pub fn new(work_dir: &Path, iso_store: IsoStore) -> Self {
let creds_root = work_dir.join("smb_creds");
let state_path = work_dir.join("smb_shares.json");
Self {
creds_root: Arc::new(creds_root),
state_path: Arc::new(state_path),
inner: Arc::new(Mutex::new(Inner::default())),
iso_store,
op_lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
/// Load persisted state and re-scan every share. Errors per share
/// are logged and surfaced on the spec; the call itself never
/// fails — startup must not block on a single offline server.
pub async fn load_and_rescan(&self) -> Result<()> {
tokio::fs::create_dir_all(self.creds_root.as_path()).await?;
let shares = match tokio::fs::read_to_string(self.state_path.as_path()).await {
Ok(text) => serde_json::from_str::<Vec<SmbShare>>(&text).unwrap_or_default(),
Err(_) => Vec::new(),
};
for mut s in shares {
s.last_error = None;
s.last_hint = None;
s.reachable = false;
self.inner.lock().shares.insert(s.id.clone(), s.clone());
if let Err(e) = self.rescan_inner(&s.id).await {
tracing::warn!(
target: "openpxe::smb",
id = %s.id, server = %s.server, share = %s.share,
"rescan on startup failed: {e}"
);
}
}
Ok(())
}
/// Add or refresh a share. Validates the input, writes a creds
/// file, probes connectivity, and scans for ISOs.
pub async fn add(
&self,
req: SmbAddRequest,
) -> std::result::Result<SmbShare, SmbShareError> {
let server = normalize_server(&req.server);
let share = req.share.trim().trim_start_matches('/').to_string();
if server.is_empty() {
return Err(SmbShareError::from_raw("server is required", ""));
}
if share.is_empty() {
return Err(SmbShareError::from_raw("share name is required", ""));
}
if share.contains('/') {
return Err(SmbShareError::from_raw(
"share name should be the top-level share (e.g. 'isos'), not a path",
"",
));
}
if server.contains('\0') || share.contains('\0') {
return Err(SmbShareError::from_raw("NUL bytes are not allowed", ""));
}
let guest = req.guest;
let username = req.username.unwrap_or_default().trim().to_string();
let password = req.password.unwrap_or_default();
if !guest && username.is_empty() {
return Err(SmbShareError::from_raw(
"username is required when 'guest' is unchecked",
"",
));
}
let port = req.port.filter(|p| *p != 0).unwrap_or(DEFAULT_SMB_PORT);
let id = share_id(&server, &share);
// Pre-flight TCP probe so a wrong IP / firewall surfaces a
// clean error instead of one of smbclient's notoriously
// cryptic NT_STATUS codes.
if let Err((err, hint)) = tcp_probe(&server, port).await {
// No share is persisted yet; just return the error.
return Err(SmbShareError {
error: err,
stderr: String::new(),
hint: Some(hint),
});
}
// Write the creds file. Even guest mode gets a file (empty
// username/password) so the code path is uniform.
let creds_path = self.creds_root.join(format!("{id}.cred"));
if let Err(e) = self.write_creds(&creds_path, &username, &password).await {
return Err(SmbShareError::from_raw(
format!("could not write credentials file: {e}"),
"",
));
}
let spec = SmbShare {
id: id.clone(),
server,
share,
username,
guest,
port,
last_error: None,
last_hint: None,
last_scan: None,
iso_count: 0,
reachable: false,
creds_path: Some(creds_path),
};
self.inner.lock().shares.insert(id.clone(), spec);
self.persist_locked();
// Now actually talk to the server.
if let Err(e) = self.rescan_inner(&id).await {
let m = self.get(&id);
return Err(SmbShareError {
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("just inserted"))
}
/// Remove a share: drops every ISO sourced from it, scrubs the
/// creds file, and forgets the spec. Idempotent.
pub async fn remove(&self, id: &str) -> Result<()> {
let creds_path = {
let mut g = self.inner.lock();
g.shares.remove(id).and_then(|s| s.creds_path)
};
self.iso_store.drop_external_source(id);
if let Some(p) = creds_path {
// Overwrite-then-unlink would be more thorough but the
// file is 0600 in a non-root-owned dir; rm is sufficient.
let _ = tokio::fs::remove_file(&p).await;
}
self.persist_locked();
Ok(())
}
/// Re-list the share and refresh the IsoStore entries.
pub async fn rescan(&self, id: &str) -> Result<u32> {
self.rescan_inner(id).await
}
/// Snapshot of every configured share, sorted by id for stable UI
/// rendering.
#[must_use]
pub fn list(&self) -> Vec<SmbShare> {
let g = self.inner.lock();
let mut v: Vec<_> = g.shares.values().cloned().collect();
v.sort_by(|a, b| a.id.cmp(&b.id));
v
}
/// Look up a share by id.
#[must_use]
pub fn get(&self, id: &str) -> Option<SmbShare> {
self.inner.lock().shares.get(id).cloned()
}
/// Open an async reader streaming an ISO out of the share. Used
/// by the HTTP ISO download handler.
///
/// Kept `async` for symmetry with the other I/O entrypoints —
/// spawning the child is sync today (no `.await` inside) but a
/// future addition (e.g. probing the share before spawn or
/// throttling concurrent smbclients) would need to await without
/// changing the call sites.
#[allow(clippy::unused_async)]
pub async fn stream_iso(
&self,
share_id: &str,
filename: &str,
) -> Result<SmbStream> {
let share = self
.get(share_id)
.ok_or_else(|| Error::Invalid(format!("no such SMB share '{share_id}'")))?;
// Defensive: reject any filename that tries to escape the
// share root. smbclient itself accepts only filenames at the
// share root in our `get` form, but belt-and-suspenders.
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return Err(Error::Invalid(format!(
"invalid filename '{filename}'"
)));
}
let creds = share
.creds_path
.as_deref()
.ok_or_else(|| Error::Invalid("share has no credentials file".into()))?;
let target = format!("//{}/{}", share.server, share.share);
let mut cmd = Command::new("smbclient");
cmd.arg(&target)
.arg("-A")
.arg(creds)
.arg("-p")
.arg(share.port.to_string())
.arg("-c")
.arg(format!("get \"{filename}\" -"))
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null());
if share.guest {
cmd.arg("-N");
}
let mut child = cmd.spawn().map_err(|e| Error::Other(e.into()))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| Error::Invalid("smbclient stdout missing".into()))?;
Ok(SmbStream { child, stdout })
}
// ── internals ─────────────────────────────────────────────────────
async fn rescan_inner(&self, id: &str) -> Result<u32> {
let _g = self.op_lock.lock().await;
let share = self
.get(id)
.ok_or_else(|| Error::Invalid(format!("no such share '{id}'")))?;
let now = OffsetDateTime::now_utc();
// Drop prior entries so a deleted file disappears from the
// store on the next scan.
self.iso_store.drop_external_source(id);
let listing = match self.list_isos(&share).await {
Ok(l) => l,
Err((err, stderr)) => {
let combined = if stderr.is_empty() {
err.clone()
} else {
format!("{err}: {stderr}")
};
let hint = hint_for(&stderr).or_else(|| hint_for(&err));
self.update_status(id, 0, false, Some(combined.clone()), hint, now);
return Err(Error::Invalid(combined));
}
};
// For each ISO we found, we still need its size + a quick
// introspection pass. The introspection pass needs random
// access into the ISO9660 PVD which lives at offset 0x8000.
// For SMB sources we can't seek without downloading the file
// first, so we use a degenerate "unknown family" introspection
// report for the listing pass. Operators can rescan after the
// first PXE boot has touched the file if they want a real
// family detection. (Better: a follow-up release adds a tiny
// `smbclient -c 'get file -'` bounded read to do introspection
// without storing the whole ISO.)
let mut count = 0u32;
for entry in listing {
let iso_id = format!("smb-{}-{}", share.id, slugify_str(&entry.filename));
// SMB sources don't get a real introspection pass — that
// would require seeking into the ISO9660 PVD over the
// network, and smbclient CLI doesn't seek. We register an
// `Unknown` family so the boot-entry generator falls back
// to generic sanboot/wimboot detection from the filename
// and the operator gets *something* bootable. A follow-up
// release can do a bounded `smbclient get` of the first
// 64 KiB for real detection.
let report = IntrospectionReport {
family: DistroFamily::Unknown,
volume_label: None,
kernel_path: None,
initrd_paths: Vec::new(),
has_boot_wim: false,
};
let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
let source = IsoSource::Smb {
share_id: share.id.clone(),
relative_path: entry.filename.clone(),
};
self.iso_store.register_external(
iso_id,
entry.filename,
entry.size,
report,
boot_entries,
source,
);
count += 1;
}
self.update_status(id, count, true, None, None, now);
tracing::info!(
target: "openpxe::smb",
id = %id, server = %share.server, share = %share.share,
iso_count = count,
"SMB share scanned"
);
Ok(count)
}
/// Spawn `smbclient //server/share -A creds -c "ls *.iso"` and
/// parse the output. Returns `(error_text, stderr_text)` on
/// failure so the caller can surface both.
async fn list_isos(
&self,
share: &SmbShare,
) -> std::result::Result<Vec<SmbListEntry>, (String, String)> {
let target = format!("//{}/{}", share.server, share.share);
let mut cmd = Command::new("smbclient");
cmd.arg(&target)
.arg("-A")
.arg(
share
.creds_path
.as_deref()
.ok_or_else(|| ("no credentials file".to_string(), String::new()))?,
)
.arg("-p")
.arg(share.port.to_string())
.arg("-c")
.arg("ls *.iso");
if share.guest {
cmd.arg("-N");
}
let output = match cmd.output().await {
Ok(o) => o,
Err(e) => {
return Err((
format!("could not exec smbclient: {e}"),
String::new(),
));
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
// smbclient writes most diagnostics to stdout too; merge
// them so we don't lose context.
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let combined = if stderr.is_empty() { stdout } else { stderr };
return Err((
format!("smbclient exit {}", output.status.code().unwrap_or(-1)),
combined,
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(parse_ls_iso(&stdout))
}
async fn write_creds(
&self,
path: &Path,
username: &str,
password: &str,
) -> std::io::Result<()> {
tokio::fs::create_dir_all(self.creds_root.as_path()).await?;
// Write the file with 0600 perms. `smbclient -A` accepts the
// standard pam_mount-style:
// username = foo
// password = bar
let body = format!(
"username = {}\npassword = {}\n",
username.replace('\n', ""),
password.replace('\n', ""),
);
// Synchronous file write to set perms atomically with the
// create — there's no async equivalent of OpenOptions+mode
// shared with the tokio API in std stable.
let path = path.to_path_buf();
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)?;
f.write_all(body.as_bytes())?;
Ok(())
})
.await
.map_err(std::io::Error::other)??;
Ok(())
}
fn update_status(
&self,
id: &str,
iso_count: u32,
reachable: bool,
err: Option<String>,
hint: Option<String>,
ts: OffsetDateTime,
) {
if let Some(s) = self.inner.lock().shares.get_mut(id) {
s.iso_count = iso_count;
s.reachable = reachable;
s.last_error = err;
s.last_hint = hint;
s.last_scan = Some(ts);
}
self.persist_locked();
}
/// Atomically replace the on-disk JSON. Persistence errors are
/// logged, never propagated.
fn persist_locked(&self) {
let shares: Vec<SmbShare> = self.inner.lock().shares.values().cloned().collect();
let path = self.state_path.as_path();
let tmp = path.with_extension("json.tmp");
let body = match serde_json::to_vec_pretty(&shares) {
Ok(b) => b,
Err(e) => {
tracing::warn!(target: "openpxe::smb", "serialize: {e}");
return;
}
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::write(&tmp, body) {
tracing::warn!(target: "openpxe::smb", "write tmp: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, path) {
tracing::warn!(target: "openpxe::smb", "rename: {e}");
}
}
}
/// Async-reader handle for an in-flight `smbclient get file -` stream.
/// Wraps the child process + its piped stdout; dropping it kills the
/// child.
#[derive(Debug)]
pub struct SmbStream {
/// Kept alive so the child isn't reaped while we're reading. The
/// `Drop` impl on `tokio::process::Child` sends SIGKILL on drop
/// when `kill_on_drop` is set; we leave that to the default
/// (no-kill) so a slow client doesn't tear down the pipe before
/// the OS finishes the read. The child exits naturally when its
/// stdout closes.
#[allow(dead_code)]
child: tokio::process::Child,
pub stdout: tokio::process::ChildStdout,
}
#[derive(Debug, Clone)]
struct SmbListEntry {
filename: String,
size: u64,
}
/// Parse `smbclient ls *.iso` output. The format is:
///
/// ```text
/// . D 0 Mon May 26 10:00:00 2026
/// .. D 0 Mon May 26 10:00:00 2026
/// ubuntu-22.04-desktop.iso A 3650912256 Mon May 26 11:00:00 2026
///
/// 4096 blocks of size 1048576. 1234 blocks available
/// ```
///
/// Each file line:
/// - starts with whitespace
/// - has the filename, then attribute flags (D=dir, A=archive, R=read-only,
/// H=hidden, S=system, N=normal), then size, then date.
///
/// We accept any line where the attributes column doesn't contain `D`
/// (i.e. not a directory) and the filename ends in `.iso` (case
/// insensitive).
fn parse_ls_iso(out: &str) -> Vec<SmbListEntry> {
let mut entries = Vec::new();
for raw in out.lines() {
let line = raw.trim();
// Skip blank lines, the connection-info banner, and the
// trailing "N blocks of size" summary. The actual filter for
// "is this a file listing?" is the attribute+size pattern
// detection below, which only matches real file rows.
if line.is_empty() || line.contains("blocks of size") {
continue;
}
// Find the attribute column: a short token of one or more of
// [DAHSRN] that follows a long-enough filename block.
// smbclient pads the filename to ~36 columns, so we can split
// on multiple consecutive spaces and then look for the
// attribute token.
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.len() < 3 {
continue;
}
// The last 5 tokens are typically: ATTR SIZE Day Mon DD HH:MM:SS YYYY
// (sometimes Day is missing depending on locale). Walk
// backwards to find ATTR + SIZE: ATTR is 1-6 chars of [DAHSRN],
// SIZE is digits.
let attr_idx = tokens.iter().enumerate().rev().find_map(|(i, t)| {
if i == 0 {
return None;
}
let next = tokens.get(i + 1)?;
let is_attr = !t.is_empty() && t.chars().all(|c| "DAHSRN".contains(c));
let is_size = next.chars().all(|c| c.is_ascii_digit()) && !next.is_empty();
if is_attr && is_size {
Some(i)
} else {
None
}
});
let Some(attr_idx) = attr_idx else { continue };
let attr = tokens[attr_idx];
// Directories aren't ISO files.
if attr.contains('D') {
continue;
}
let size_tok = tokens[attr_idx + 1];
let Ok(size) = size_tok.parse::<u64>() else {
continue;
};
// The filename is everything before the attribute token in
// the original (un-tokenized) line — we need the original
// because filenames can contain spaces.
// Locate the attribute token's start column by counting
// characters in the prior tokens + separators. Simpler: find
// the index of the attribute in the trimmed line by joining
// and trimming again.
let joined_before: String = tokens[..attr_idx].join(" ");
let name = joined_before.trim().to_string();
if name.is_empty() || name == "." || name == ".." {
continue;
}
if !name.to_ascii_lowercase().ends_with(".iso") {
continue;
}
entries.push(SmbListEntry {
filename: name,
size,
});
}
entries
}
/// Pre-flight TCP probe to `server:port`. Format matches v0.4.64 NFS
/// probe so the UI banner reads consistently.
async fn tcp_probe(
server: &str,
port: u16,
) -> std::result::Result<(), (String, String)> {
use tokio::net::TcpStream;
let addr = format!("{server}:{port}");
match tokio::time::timeout(PROBE_TIMEOUT, TcpStream::connect(&addr)).await {
Ok(Ok(_)) => Ok(()),
Ok(Err(e)) => Err((
format!("cannot reach SMB port: {addr}: {e}"),
format!(
"verify the SMB service is running on {server} and that port {port} is open"
),
)),
Err(_) => Err((
format!(
"cannot reach SMB 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()
),
)),
}
}
/// Translate well-known smbclient stderr patterns into actionable
/// hints. Returns `None` when we don't have a translation.
fn hint_for(text: &str) -> Option<String> {
let s = text.to_ascii_lowercase();
if s.contains("nt_status_logon_failure") || s.contains("logon_failure") {
Some(
"the server rejected the credentials. Double-check the username \
and password — many NAS appliances use a separate SMB account \
rather than the system login."
.into(),
)
} else if s.contains("nt_status_access_denied") || s.contains("access_denied") {
Some(
"the credentials worked but the account doesn't have read \
access to this share. Check the share's permissions on the \
server."
.into(),
)
} else if s.contains("nt_status_bad_network_name")
|| s.contains("nt_status_bad_network_path")
|| s.contains("bad_network_name")
{
Some(
"the share name doesn't exist on this server. Enter just the \
share name (e.g. 'isos'), not a path. Use `smbclient -L \
//server` to list shares manually."
.into(),
)
} else if s.contains("connection refused") {
Some(
"the SMB service isn't accepting connections on this port. \
Verify smbd / Samba is running on the server."
.into(),
)
} else if s.contains("connection timed out") || s.contains("no route to host") {
Some(
"the server isn't reachable on this network. Check the IP and \
any firewall in between."
.into(),
)
} else if s.contains("nt_status_network_unreachable") {
Some(
"the server's network is unreachable from this container — \
check the host networking setup."
.into(),
)
} else if s.contains("does not exist") || s.contains("not a directory") {
Some(
"the listed path doesn't exist on the share. Make sure the \
share name is the top-level share, not a sub-path."
.into(),
)
} else if s.contains("session setup failed") {
Some(
"session setup failed — usually a protocol / dialect mismatch. \
Most modern servers speak SMB2/3; very old shares (XP) may \
need legacy support enabled on the server."
.into(),
)
} else {
None
}
}
fn share_id(server: &str, share: &str) -> String {
slugify_str(&format!("{server}-{share}"))
}
/// Normalize a server input: trim, strip scheme prefix the operator
/// may have pasted, and drop trailing slashes. UNC-style `\\server`
/// and `//server` prefixes are also accepted.
fn normalize_server(raw: &str) -> String {
let s = raw.trim();
let s = s
.strip_prefix("smb://")
.or_else(|| s.strip_prefix("cifs://"))
.or_else(|| s.strip_prefix("\\\\"))
.or_else(|| s.strip_prefix("//"))
.unwrap_or(s);
s.trim_end_matches('/').trim_end_matches('\\').to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn share_id_is_stable_and_safe() {
let a = share_id("10.0.0.5", "isos");
let b = share_id("10.0.0.5", "isos");
assert_eq!(a, b);
assert!(!a.contains('/'));
assert!(!a.contains('.'));
}
#[test]
fn normalize_server_strips_url_and_unc_prefixes() {
assert_eq!(normalize_server(" 10.0.0.5 "), "10.0.0.5");
assert_eq!(normalize_server("smb://nas.lan/"), "nas.lan");
assert_eq!(normalize_server("cifs://192.168.1.51"), "192.168.1.51");
assert_eq!(normalize_server("\\\\192.168.1.51\\"), "192.168.1.51");
assert_eq!(normalize_server("//nas.lan//"), "nas.lan");
assert_eq!(normalize_server("nas.lan"), "nas.lan");
}
#[test]
fn hint_for_logon_failure_calls_out_credentials() {
let h = hint_for("session setup failed: NT_STATUS_LOGON_FAILURE").unwrap();
assert!(h.to_lowercase().contains("credentials"));
}
#[test]
fn hint_for_bad_share_name_calls_out_share_lookup() {
let h = hint_for("tree connect failed: NT_STATUS_BAD_NETWORK_NAME").unwrap();
assert!(h.to_lowercase().contains("share"));
}
#[test]
fn hint_for_unknown_is_none() {
assert!(hint_for("some unrelated error text").is_none());
}
#[test]
fn parse_ls_iso_finds_one_iso_and_skips_directories() {
let out = "\
\tDomain=[WORKGROUP] OS=[Windows] Server=[Samba]\n\
. D 0 Mon May 26 10:00:00 2026\n\
.. D 0 Mon May 26 10:00:00 2026\n\
ubuntu-22.04-desktop.iso A 3650912256 Mon May 26 11:00:00 2026\n\
\n\
\t\t4096 blocks of size 1048576. 1234 blocks available\n\
";
let entries = parse_ls_iso(out);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].filename, "ubuntu-22.04-desktop.iso");
assert_eq!(entries[0].size, 3_650_912_256);
}
#[test]
fn parse_ls_iso_handles_filenames_with_spaces() {
let out = "\
Windows Server 2025.iso A 5000000000 Tue May 27 09:00:00 2026\n\
";
let entries = parse_ls_iso(out);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].filename, "Windows Server 2025.iso");
assert_eq!(entries[0].size, 5_000_000_000);
}
#[test]
fn parse_ls_iso_skips_non_iso_files() {
let out = "\
readme.txt A 100 Tue May 27 09:00:00 2026\n\
archive.zip A 5000 Tue May 27 09:00:00 2026\n\
";
let entries = parse_ls_iso(out);
assert!(entries.is_empty());
}
#[test]
fn add_request_requires_username_when_not_guest() {
// We can't easily test the add() path against a real SMB
// server in unit tests, but we can confirm the validation
// logic at least serializes the request shape we expect. The
// actual auth check happens in add() itself which we cover in
// integration tests against a stub server.
let req = SmbAddRequest {
server: "10.0.0.5".into(),
share: "isos".into(),
username: None,
password: None,
guest: false,
port: None,
};
// No SmbShareManager here — we just check the field shape
// matches what UI submits.
assert!(!req.guest);
assert!(req.username.is_none());
}
}
+66 -45
View File
@@ -16,17 +16,24 @@ use tokio::io::AsyncWriteExt;
/// Where the bytes for an ISO actually live.
///
/// The default is `Local` — uploaded ISOs sit in `<iso_dir>/<id>.iso`.
/// `Nfs` entries point at a file inside a remote share that the
/// `NfsManager` is keeping mounted. We resolve the on-disk path lazily
/// in [`IsoStore::iso_path_for`] using the `nfs_root` set at startup.
/// `Smb` entries (v0.4.65) point at a file inside a remote SMB share
/// that the `SmbShareManager` knows how to stream via Samba's
/// userspace `smbclient` CLI. The HTTP handler resolves the share by
/// id at request time and pipes `smbclient -c 'get file -'` straight
/// into the response body — no kernel mount, no local cache.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum IsoSource {
#[default]
Local,
Nfs {
mount_id: String,
/// Path relative to the mount point — typically just the filename.
/// v0.4.65: kernel-mount NFS is gone (it didn't work on Unraid
/// regardless of capabilities — the host kernel needs the nfs
/// client modules loaded). SMB via userspace `smbclient` works in
/// any container.
Smb {
share_id: String,
/// Filename at the share root. We don't support nested paths
/// in v0.4.65; ISOs live at the top of the share.
relative_path: String,
},
}
@@ -164,10 +171,6 @@ struct Inner {
#[derive(Debug, Clone)]
pub struct IsoStore {
iso_dir: Arc<PathBuf>,
/// Where NFS mounts land on disk. Set at startup via
/// [`IsoStore::set_nfs_root`]; required for resolving any
/// `IsoSource::Nfs` entry.
nfs_root: Arc<RwLock<Option<PathBuf>>>,
inner: Arc<RwLock<Inner>>,
}
@@ -175,17 +178,10 @@ impl IsoStore {
pub fn new(iso_dir: PathBuf) -> Self {
Self {
iso_dir: Arc::new(iso_dir),
nfs_root: Arc::new(RwLock::new(None)),
inner: Arc::new(RwLock::new(Inner::default())),
}
}
/// Tell the store where NFS mounts live. Without this set,
/// `IsoSource::Nfs` entries cannot be resolved to a file path.
pub fn set_nfs_root(&self, root: PathBuf) {
*self.nfs_root.write() = Some(root);
}
pub async fn ensure_dirs(&self) -> Result<()> {
tokio::fs::create_dir_all(self.iso_dir.as_path()).await?;
Ok(())
@@ -281,33 +277,32 @@ impl IsoStore {
self.inner.read().isos.get(id).cloned()
}
/// Resolve an ISO id to its on-disk path, if any. For local entries
/// this is `<iso_dir>/<id>.iso`; for NFS entries it's
/// `<nfs_root>/<mount_id>/<relative_path>`. Returns None if the file
/// is missing or the source isn't resolvable (e.g. NFS share
/// unmounted).
/// Resolve an ISO id to its on-disk path, if any. For local
/// (uploaded) ISOs this is `<iso_dir>/<id>.iso`. For SMB-sourced
/// ISOs there is no on-disk path — the HTTP handler must stream
/// via `SmbShareManager::stream_iso` instead. Returns `None` for
/// SMB sources or when the file is missing.
pub fn iso_path_for(&self, id: &str) -> Option<PathBuf> {
let meta = self.get(id)?;
let path = match &meta.source {
IsoSource::Local => self.iso_path(id),
IsoSource::Nfs {
mount_id,
relative_path,
} => {
let root = self.nfs_root.read().clone()?;
root.join(mount_id).join(relative_path)
}
};
match &meta.source {
IsoSource::Local => {
let path = self.iso_path(id);
if path.exists() {
Some(path)
} else {
None
}
}
// SMB sources have no local path — they're streamed via
// smbclient subprocess. Callers should check the source
// kind first and dispatch accordingly.
IsoSource::Smb { .. } => None,
}
}
/// Delete an ISO and its sidecar metadata. Only acts on local ISOs;
/// for NFS-backed ISOs the operator must remove the file from the
/// share or unmount the NFS share entirely.
/// Delete an ISO and its sidecar metadata. Only acts on local
/// (uploaded) ISOs; for SMB-backed ISOs the operator must remove
/// the file from the share or unregister the share entirely.
pub async fn delete(&self, id: &str) -> Result<()> {
let meta = self.get(id);
let is_local = matches!(
@@ -324,10 +319,10 @@ impl IsoStore {
Ok(())
}
/// Register an externally-sourced ISO (e.g. NFS-mounted). Used by
/// `NfsManager` after walking a freshly-mounted share. We do **not**
/// persist a `meta.json` on disk for these — the source of truth is
/// the share itself, and the NFS manager re-scans on startup.
/// Register an externally-sourced ISO (SMB share, etc.). Used by
/// `SmbShareManager` after listing a share. We do **not** persist
/// a `meta.json` on disk for these — the source of truth is the
/// share itself, and the manager re-scans on startup.
pub fn register_external(
&self,
id: String,
@@ -352,13 +347,13 @@ impl IsoStore {
self.inner.write().isos.insert(id, meta);
}
/// Drop every entry that belongs to `mount_id`. Used by the NFS
/// manager when an operator removes a share, or before re-scanning
/// to clean out stale entries.
pub fn drop_external_source(&self, mount_id: &str) {
/// Drop every entry that belongs to `share_id`. Used by the SMB
/// share manager when an operator removes a share, or before
/// re-scanning to clean out stale entries.
pub fn drop_external_source(&self, share_id: &str) {
let mut g = self.inner.write();
g.isos.retain(
|_, m| !matches!(&m.source, IsoSource::Nfs { mount_id: mid, .. } if mid == mount_id),
|_, m| !matches!(&m.source, IsoSource::Smb { share_id: sid, .. } if sid == share_id),
);
}
@@ -596,8 +591,17 @@ fn linux_cmdline(family: DistroFamily, id: &str) -> String {
// The HTTP layer resolves `${base-url}` at render time.
let iso_url = format!("${{base-url}}/iso/{id}.iso");
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!(
"boot=casper netboot=url url={iso_url} ip=dhcp ---"
"boot=casper initrd=initrd ds=nocloud ip=dhcp iso-url={iso_url}"
),
DistroFamily::RhelFedora => format!(
"inst.repo={iso_url} inst.stage2={iso_url} ip=dhcp"
@@ -632,6 +636,23 @@ mod tests {
assert_eq!(slugify("/etc/passwd"), "passwd");
}
#[test]
fn casper_cmdline_vmware_uefi_safe() {
// v0.4.5 regression guard: the Debian/Ubuntu cmdline must use
// the canonical Casper `iso-url=` option and include
// `ds=nocloud` so VMware-UEFI guests don't hang at "cloud-init
// running" waiting on a metadata datasource that PXE can't
// provide. The legacy `netboot=url url=… ---` form is gone for
// good.
let s = linux_cmdline(DistroFamily::DebianUbuntu, "ubuntu-24-04");
assert!(s.contains("boot=casper"), "{s}");
assert!(s.contains("iso-url=${base-url}/iso/ubuntu-24-04.iso"), "{s}");
assert!(s.contains("ds=nocloud"), "{s}");
assert!(s.contains("ip=dhcp"), "{s}");
assert!(!s.contains("netboot=url"), "legacy option leaked: {s}");
assert!(!s.contains(" --- "), "stray ---: {s}");
}
fn fake_meta(id: &str) -> IsoMeta {
IsoMeta {
id: id.into(),
+20 -9
View File
@@ -9,7 +9,7 @@ use openpxe_core::{
};
use openpxe_dhcp_proxy::DhcpProxyServer;
use openpxe_http_api::{build_router, AppState};
use openpxe_iso_store::{IsoStore, NfsManager, SmbManager};
use openpxe_iso_store::{IsoStore, SmbManager, SmbShareManager};
use openpxe_tftp::TftpServer;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
@@ -105,6 +105,9 @@ async fn main() -> anyhow::Result<()> {
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();
// Build the SMB manager unconditionally — it starts/stops on the
@@ -116,13 +119,18 @@ async fn main() -> anyhow::Result<()> {
let _ = smb.start();
}
// NFS manager. The mount root has to be set on the IsoStore *before*
// we replay any persisted mounts, otherwise an in-memory IsoMeta
// pointing at an NFS source can't resolve to a path.
let nfs = NfsManager::new(&config.paths.work_dir, iso_store.clone());
iso_store.set_nfs_root(nfs.mount_root());
if let Err(e) = nfs.load_and_remount().await {
tracing::warn!(target: "openpxe::nfs", "could not reload NFS mounts: {e}");
// v0.4.65: SMB share manager — Samba `smbclient` userspace
// consumer. Replaces the kernel-mount NFS path that v0.4.64
// shipped; that didn't work on hosts whose kernel lacked the nfs
// client modules (Unraid is the dominant case). `smbclient` does
// the SMB protocol entirely in userspace over TCP and works in
// any container regardless of capabilities or kernel modules.
let smb_shares = SmbShareManager::new(&config.paths.work_dir, iso_store.clone());
if let Err(e) = smb_shares.load_and_rescan().await {
tracing::warn!(
target: "openpxe::smb",
"could not reload SMB shares on startup: {e}"
);
}
// Sniff network details for the Network tab. None of these are
@@ -144,9 +152,12 @@ async fn main() -> anyhow::Result<()> {
hosts: hosts.clone(),
boot_log: boot_log.clone(),
branding: branding.clone(),
admin: admin.clone(),
sessions: sessions.clone(),
sso: sso.clone(),
metrics: metrics.clone(),
smb: Some(smb.clone()),
nfs: nfs.clone(),
smb_shares: smb_shares.clone(),
uploads: openpxe_http_api::uploads::UploadSessions::default(),
log_bus: log_bus.clone(),
started_at: time::OffsetDateTime::now_utc(),
+201 -6
View File
@@ -34,9 +34,18 @@
--topbar-h: 56px;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
/* v0.4.63: tie native form-control rendering (checkboxes, scroll bars,
date pickers) to the active OpenPXE theme. Without this, the inline
`<meta name="color-scheme" content="dark light">` in index.html forces
dark form chrome in *both* themes — so the SSO "Enable single sign-on"
checkbox renders as an opaque black square against the light-mode
panel, ignoring our accent-color hint. CSS `color-scheme` overrides
the meta and tracks `data-theme` correctly. */
color-scheme: dark;
}
:root[data-theme="light"] {
color-scheme: light;
/* Light palette — high-contrast neutral, accent unchanged for brand
consistency. Designed against Netbox Labs's reference screenshot:
near-white surfaces, soft grey dividers, dark text. */
@@ -287,23 +296,83 @@ label.field {
}
label.field .name { color: var(--fg-dim); font-size: 12px; }
label.field .hint { color: var(--fg-dimmer); font-size: 11px; }
label.field input[type="text"],
label.field input[type="number"],
/* All single-line inputs share one chrome rule. Pre-v0.4.6 we only
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 textarea {
width: 100%; background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: var(--radius);
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 {
outline: none; border-color: var(--accent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
}
label.check {
display: flex; gap: 10px; align-items: center;
padding: 8px 10px; margin-bottom: 6px;
border: 1px solid var(--border-soft); border-radius: var(--radius);
}
label.check input { accent-color: var(--accent); }
/* v0.4.63: native checkboxes used to render as opaque black squares in
light mode because the page meta declares `color-scheme: dark light`
and `accent-color` alone only repaints the *check mark* (not the
container). Take full control of the chrome so the box reads cleanly
on both palettes and the checked state lights up in our accent. */
label.check input[type="checkbox"] {
appearance: none; -webkit-appearance: none;
width: 16px; height: 16px; flex: none;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 3px;
display: inline-grid; place-content: center;
cursor: pointer; margin: 0;
transition: background 0.1s ease, border-color 0.1s ease;
}
label.check input[type="checkbox"]:hover { border-color: var(--accent); }
label.check input[type="checkbox"]:checked {
background: var(--accent);
border-color: var(--accent);
}
label.check input[type="checkbox"]:checked::after {
/* Classic ✓ glyph built from a rotated rectangle border. Colour is
#002923 (the same near-black we use on solid-accent buttons) so the
tick stays legible against the teal fill in both themes. */
content: '';
width: 4px; height: 8px;
border: solid #002923;
border-width: 0 2px 2px 0;
transform: rotate(45deg) translate(-1px, -1px);
}
label.check input[type="checkbox"]:focus-visible {
outline: none;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
}
/* ── Drop zone ────────────────────────────────────────────────────── */
@@ -421,9 +490,21 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
.dot.err { background: var(--err); }
.dot.warn { background: var(--warn); }
/* Inline form rows. */
.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; }
@media (max-width: 900px) { .form-row { grid-template-columns: 1fr; } }
/* Inline form rows. The default is a 4-column grid sized for the
Account card's "Current / New username / New password / Confirm"
quartet; the `.cols-3` modifier swaps to a 3-column layout for the
SSO header strip (display name / logo URL / metadata source). All
`.form-row > label.field` children share the same baseline because
their inner inputs share metrics via the global rule above. */
.form-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 14px; align-items: end; }
.form-row.cols-3 { grid-template-columns: repeat(3, 1fr); }
.form-row.cols-2 { grid-template-columns: repeat(2, 1fr); }
.form-row label.field { margin-bottom: 0; }
@media (max-width: 900px) {
.form-row,
.form-row.cols-3,
.form-row.cols-2 { grid-template-columns: 1fr; }
}
/* ── Queued deployment visual ────────────────────────────────────── */
.queue-track {
@@ -504,6 +585,120 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
}
.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-hero { padding: 20px 24px; }
.about-hero h2 { font-size: 22px; margin: 0 0 8px; color: var(--fg); }
+648 -87
View File
@@ -63,16 +63,37 @@
};
// ── network helpers ──────────────────────────────────────────────
// All three helpers funnel through a 401 detector. When the server
// says "auth required" mid-session — most commonly because the
// operator's session expired while the tab was idle — we transparently
// swap the SPA out for the login screen rather than letting the UI
// throw a generic error.
function maybeAuthBounce(r) {
if (r && r.status === 401) {
// Render the login screen without reloading; any in-flight
// promises still return their values to the original caller.
showAuthScreen('login');
}
return r;
}
async function getJSON(url) {
const r = await fetch(url);
const r = maybeAuthBounce(await fetch(url));
if (!r.ok) throw new Error(url + ': ' + r.status);
return r.json();
}
async function putJSON(url, body) {
return fetch(url, {method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
return maybeAuthBounce(await fetch(url, {
method:'PUT',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(body),
}));
}
async function postJSON(url, body) {
return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
return maybeAuthBounce(await fetch(url, {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(body),
}));
}
// Animated brand-mark + progress bar widget. Built once here and inlined
@@ -146,7 +167,8 @@
el('div', {class: 'trend'},
isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' +
isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux · ' +
(status.nfs_active || 0) + ' NFS active'),
(status.smb_share_reachable || 0) + ' SMB share' +
((status.smb_share_reachable || 0) === 1 ? '' : 's')),
])),
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
el('div', {class: 'label'}, 'Uptime'),
@@ -321,13 +343,18 @@
},
storage: async () => {
const [isos, settings, nfsRes, disk] = await Promise.all([
getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/nfs'),
// v0.4.65: kernel-mount NFS replaced with userspace SMB via
// smbclient — works in any container regardless of host kernel
// modules or capabilities. The /api/nfs endpoint is gone;
// /api/smb-shares is the replacement.
const [isos, settings, smbRes, disk] = await Promise.all([
getJSON('/api/isos'), getJSON('/api/settings'),
getJSON('/api/smb-shares'),
getJSON('/api/storage/disk').catch(() => ({
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
})),
]);
const mounts = nfsRes.mounts || [];
const shares = smbRes.shares || [];
// ── Upload card ──
const drop = el('div', {class:'drop', id:'drop'}, [
@@ -429,7 +456,7 @@
}
}
// ── ISO table (mixed local + NFS) ──
// ── ISO table (mixed local + SMB) ──
// Each row gets a "Password" cell that toggles a small inline
// editor (a checkbox + a password field + Save button) inside the
// *next* row of the table. Keeps the markup flat and avoids the
@@ -437,7 +464,10 @@
const rowsAndEditors = [];
isos.forEach(i => {
const b = bootability(i, settings);
const isNfs = i.source && i.source.kind === 'nfs';
// v0.4.65: SMB userspace consumer replaced NFS. The badge
// colours stay the same so the table looks unchanged for
// existing operators.
const isSmb = i.source && i.source.kind === 'smb';
const protectedNow = !!i.password_hash;
// The inline editor row is hidden by default; the Password
@@ -557,8 +587,8 @@
]),
el('td', {class:'num'}, fmtBytes(i.size_bytes)),
el('td', {},
el('span', {class:'src-badge' + (isNfs ? ' nfs' : '')},
isNfs ? ('nfs:' + i.source.mount_id) : 'local')),
el('span', {class:'src-badge' + (isSmb ? ' nfs' : '')},
isSmb ? ('smb:' + i.source.share_id) : 'local')),
el('td', {},
protectedNow
? el('span', {class:'tag accent'}, 'protected')
@@ -568,8 +598,11 @@
el('button', {class:'ghost', style:'margin-right:6px', onclick: () => {
editorRow.style.display = (editorRow.style.display === 'none') ? '' : 'none';
}}, protectedNow ? 'Password ✎' : 'Set password'),
isNfs
? el('span', {class:'tag', style:'opacity:.6'}, 'on NFS')
isSmb
// v0.4.65: SMB-sourced ISOs live on the remote share —
// OpenPXE doesn't own those bytes. Same pattern as NFS
// had: surface a tag instead of a destructive button.
? el('span', {class:'tag', style:'opacity:.6'}, 'on SMB')
: el('button', {class:'danger', onclick: async () => {
if (!confirm('Remove this image?')) return;
await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'});
@@ -589,56 +622,98 @@
])),
el('tbody', {}, rowsAndEditors),
])
: el('div', {class:'empty'}, 'No images yet. Upload an ISO or mount an NFS share.');
: el('div', {class:'empty'}, 'No images yet. Upload an ISO or add an SMB share.');
// ── NFS section ──
const nfsMsg = el('div', {class:'msg'});
const nfsServer = el('input', {type:'text', placeholder:'10.0.0.20'});
const nfsExport = el('input', {type:'text', placeholder:'/srv/isos'});
const nfsVer = el('select', {}, [
el('option', {value:'v41'}, 'NFSv4.1 (default)'),
el('option', {value:'v3'}, 'NFSv3'),
]);
const nfsRo = el('input', {type:'checkbox'}); nfsRo.checked = true;
const addNfs = el('button', {onclick: async () => {
if (!nfsServer.value || !nfsExport.value) {
nfsMsg.textContent = 'Server and export are required.'; nfsMsg.className='msg err'; return;
// ── SMB shares section (v0.4.65) ──
// Replaces the kernel-mount NFS card. SMB shares are consumed
// in userspace via Samba's `smbclient` CLI — no kernel modules,
// no CAP_SYS_ADMIN, works in any container. This is the same
// approach Bootimus uses.
const smbMsg = el('div', {class:'msg'});
const smbServer = el('input', {type:'text', placeholder:'192.168.1.51'});
const smbShare = el('input', {type:'text', placeholder:'isos'});
const smbGuest = el('input', {type:'checkbox'}); smbGuest.checked = true;
const smbUser = el('input', {type:'text', placeholder:'(disabled when Guest)'});
const smbPass = el('input', {type:'password', placeholder:'(disabled when Guest)'});
// Toggle username/password fields based on the Guest checkbox so
// operators don't get confused about which fields matter.
const syncAuthDisabled = () => {
smbUser.disabled = smbGuest.checked;
smbPass.disabled = smbGuest.checked;
smbUser.style.opacity = smbGuest.checked ? '0.55' : '1';
smbPass.style.opacity = smbGuest.checked ? '0.55' : '1';
};
smbGuest.addEventListener('change', syncAuthDisabled);
syncAuthDisabled();
const addSmb = el('button', {onclick: async () => {
if (!smbServer.value || !smbShare.value) {
smbMsg.replaceChildren(document.createTextNode('Server and share name are required.'));
smbMsg.className='msg err'; return;
}
nfsMsg.textContent = 'Mounting…'; nfsMsg.className = 'msg';
const r = await postJSON('/api/nfs', {
server: nfsServer.value, export: nfsExport.value,
version: nfsVer.value, read_only: nfsRo.checked,
});
if (!smbGuest.checked && !smbUser.value) {
smbMsg.replaceChildren(document.createTextNode('Username is required when Guest is unchecked.'));
smbMsg.className='msg err'; return;
}
smbMsg.replaceChildren(document.createTextNode('Connecting…'));
smbMsg.className = 'msg';
const body = {
server: smbServer.value,
share: smbShare.value,
guest: smbGuest.checked,
};
if (!smbGuest.checked) {
body.username = smbUser.value;
body.password = smbPass.value;
}
const r = await postJSON('/api/smb-shares', body);
if (r.ok) {
nfsMsg.textContent = 'Mounted.'; nfsMsg.className = 'msg ok';
smbMsg.replaceChildren(document.createTextNode('Connected.'));
smbMsg.className = 'msg ok';
render('storage');
} else {
const t = await r.text();
nfsMsg.textContent = 'Mount failed: ' + t; nfsMsg.className = 'msg err';
// The API returns a structured {error, stderr, hint} JSON
// body on failure so the raw smbclient error and the
// actionable hint render as two distinct lines.
let bodyJson = null;
let raw = null;
try { bodyJson = await r.clone().json(); }
catch (_) { raw = await r.text().catch(()=> 'connect failed'); }
const msg = bodyJson && bodyJson.error ? bodyJson.error : (raw || 'connect failed');
const hint = bodyJson && bodyJson.hint;
const parts = [el('div', {}, [
el('strong', {}, 'Connect failed: '),
document.createTextNode(msg),
])];
if (hint) {
parts.push(el('div', {style:'margin-top:6px;opacity:.78;font-size:12px'}, hint));
}
}}, 'Mount share');
smbMsg.replaceChildren(...parts);
smbMsg.className = 'msg err';
}
}}, 'Add share');
const nfsRows = mounts.length ? mounts.map(m => el('div', {class: 'nfs-row' + (m.mounted ? '' : ' down')}, [
el('span', {class: 'dot ' + (m.mounted ? 'ok' : 'err')}),
const smbRows = shares.length ? shares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
el('div', {}, [
el('div', {class:'id'}, m.server + ':' + m.export),
el('div', {class:'id'}, '//' + m.server + '/' + m.share),
el('div', {class:'meta'},
(m.version === 'v3' ? 'NFSv3' : 'NFSv4.1') + ' · ' +
(m.read_only ? 'read-only' : 'read-write') + ' · ' +
(m.mounted ? m.iso_count + ' isos' : 'not mounted')),
(m.guest ? 'guest' : ('user: ' + (m.username || '?'))) + ' · ' +
(m.reachable ? m.iso_count + ' isos' : 'not reachable')),
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
]),
el('button', {class:'ghost', onclick: async () => {
const r = await postJSON('/api/nfs/' + encodeURIComponent(m.id) + '/scan', {});
const r = await postJSON('/api/smb-shares/' + encodeURIComponent(m.id) + '/scan', {});
if (r.ok) render('storage');
}}, 'Re-scan'),
el('button', {class:'danger', onclick: async () => {
if (!confirm('Unmount ' + m.server + ':' + m.export + '?')) return;
await fetch('/api/nfs/' + encodeURIComponent(m.id), {method:'DELETE'});
if (!confirm('Forget //' + m.server + '/' + m.share + '?')) return;
await fetch('/api/smb-shares/' + encodeURIComponent(m.id), {method:'DELETE'});
render('storage');
}}, 'Unmount'),
}}, 'Remove'),
el('span'),
])) : [el('div', {class:'empty'}, 'No NFS shares mounted.')];
])) : [el('div', {class:'empty'}, 'No SMB shares configured.')];
// Disk-space card. Free + used + total for the volume hosting the
// ISO directory, with a coloured bar. Warns at 80% and goes red at
@@ -688,34 +763,42 @@
]),
el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'NFS shares'),
el('span', {class:'sub'}, mounts.length + ' configured'),
el('h2', {}, 'SMB shares'),
el('span', {class:'sub'}, shares.length + ' configured'),
]),
el('div', {class:'body'}, [
el('div', {class:'form-row'}, [
el('div', {class:'form-row cols-2'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'NFS server'),
nfsServer,
el('span', {class:'name'}, 'SMB server'),
smbServer,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Export path'),
nfsExport,
el('span', {class:'name'}, 'Share name'),
smbShare,
]),
]),
el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [
el('label', {class:'check'}, [
smbGuest, el('span', {}, 'Guest (anonymous read)'),
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Version'),
nfsVer,
el('span', {class:'name'}, 'Username'),
smbUser,
]),
el('label', {class:'check', style:'margin-top:18px'}, [
nfsRo, el('span', {}, 'Read-only'),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Password'),
smbPass,
]),
]),
addNfs, nfsMsg,
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, nfsRows),
addSmb, smbMsg,
el('div', {style:'margin-top:18px;display:grid;gap:8px'}, smbRows),
el('p', {class:'msg', style:'margin-top:14px'},
'Mounting NFS inside a container requires CAP_SYS_ADMIN and the ' +
'mount.nfs binary (bundled in the default Docker image). On ' +
'OpenShift, your SCC must allow CAP_SYS_ADMIN or you can run ' +
'NFS mounts as a CSI driver outside the pod.'),
'SMB shares are read in userspace via Sambas smbclient — ' +
'no kernel modules, no CAP_SYS_ADMIN, works in any container ' +
'(Unraid, OpenShift restricted SCC, plain Docker, etc.). Most NAS ' +
'appliances expose ISO libraries as guest-readable; check the box ' +
'above when thats the case. ISOs are streamed on demand at PXE ' +
'boot time — no local cache, no double disk usage.'),
]),
]),
el('div', {class:'card'}, [
@@ -981,43 +1064,241 @@
},
settings: async () => {
const [status, docs] = await Promise.all([
const [status, docs, me, sso] = await Promise.all([
getJSON('/api/status'),
getJSON('/api/docs').catch(() => ({ groups: [] })),
getJSON('/api/me').catch(() => ({})),
getJSON('/api/sso').catch(() => ({
enabled:false, idp_name:'', metadata:'', metadata_url:'',
})),
]);
const hasLogo = !!status.custom_logo;
// ── Identity & access placeholder.
// We haven't shipped auth yet — but operators looking at this tab
// need to see *where* it'll live so they're not surprised when an
// upgrade lights up real config controls under the same heading.
const accessCard = el('div', {class:'card'}, [
el('header', {}, el('h2', {}, 'Identity & access')),
// ── Account card (Forms admin credentials, v0.4.5).
// Sonarr/Radarr-style: the admin enters their current password
// before changing username or password. On success the server
// revokes every other session, so a forgotten browser tab can't
// keep operating with stale credentials.
const currentPw = el('input', {type:'password', autocomplete:'current-password'});
const newUser = el('input', {type:'text', autocomplete:'username',
placeholder: (me.user && me.user.username) || 'admin'});
const newPw = el('input', {type:'password', autocomplete:'new-password',
placeholder: 'leave blank to keep current'});
const newPwConfirm = el('input', {type:'password', autocomplete:'new-password',
placeholder: 'confirm new password'});
const accountMsg = el('div', {class:'msg', style:'margin-top:8px'});
// v0.4.63: explicit top margin so the action button sits clearly
// beneath the input row instead of butting against the password
// fields. Mirrors the `Save SSO settings` button below for visual
// parity between the two settings cards.
const accountSave = el('button', {style:'margin-top:6px', onclick: async () => {
accountMsg.textContent = ''; accountMsg.className = 'msg';
if (!currentPw.value) {
accountMsg.textContent = 'Current password is required.';
accountMsg.className = 'msg err';
return;
}
if (newPw.value && newPw.value !== newPwConfirm.value) {
accountMsg.textContent = 'New password and confirmation do not match.';
accountMsg.className = 'msg err';
return;
}
if (!newUser.value && !newPw.value) {
accountMsg.textContent = 'Nothing to change. Fill in a new username or password.';
accountMsg.className = 'msg err';
return;
}
const body = { current_password: currentPw.value };
if (newUser.value) body.new_username = newUser.value;
if (newPw.value) body.new_password = newPw.value;
const r = await putJSON('/api/me/credentials', body);
// Clear the typed plaintext immediately — minimises DOM dwell time.
currentPw.value = ''; newPw.value = ''; newPwConfirm.value = '';
if (r.ok) {
accountMsg.textContent = 'Credentials updated. Other sessions were signed out.';
accountMsg.className = 'msg ok';
// Refresh the settings view to pick up the new "logged in as" display.
setTimeout(() => render('settings'), 600);
} else {
const j = await r.json().catch(() => ({}));
accountMsg.textContent = j.error || ('Update failed: HTTP ' + r.status);
accountMsg.className = 'msg err';
}
}}, 'Update credentials');
const accountCard = el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Administrator account'),
el('span', {class:'sub'},
(me && me.user && me.user.username)
? ('signed in as ' + me.user.username)
: 'signed in'),
]),
el('div', {class:'body'}, [
el('p', {class:'msg'},
'LDAP, SSO (OIDC), and operator user management are planned ' +
'for a future release. Today, OpenPXE assumes a single trusted ' +
'operator on a flat L2 network and provides no built-in ' +
'authentication on the WebUI or HTTP API. Run behind your ' +
'identity-aware reverse proxy (Authentik, Authelia, oauth2-proxy) ' +
'for a hardened deployment until first-class support lands here.'),
el('div', {class:'form-row', style:'opacity:.55;pointer-events:none'}, [
el('p', {class:'msg', style:'margin-bottom:14px'},
'Rotate the administrator login. Your current password is required ' +
'to make any change; on success every other browser session is ' +
'signed out so a stale cookie can\'t keep operating.'),
el('div', {class:'form-row'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'LDAP server URL'),
el('input', {type:'text', placeholder:'ldaps://dc.example.com:636', disabled:'disabled'}),
el('span', {class:'name'}, 'Current password'),
currentPw,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'OIDC issuer'),
el('input', {type:'text', placeholder:'https://idp.example.com/realms/openpxe', disabled:'disabled'}),
el('span', {class:'name'}, 'New username (optional)'),
newUser,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'User management'),
el('input', {type:'text', placeholder:'configurable in a future release', disabled:'disabled'}),
el('span', {class:'name'}, 'New password (optional)'),
newPw,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Confirm new password'),
newPwConfirm,
]),
]),
accountSave, accountMsg,
]),
]);
// ── SSO card (FleetDM-shaped, storage-only for v0.4.5).
// Operators paste either a metadata URL or the raw XML; tabs
// switch the visible field. Saving validates server-side. The
// actual SAML login flow ships in a later release — we surface
// a yellow "config saved, runtime pending" line when usable.
const ssoEnabled = el('input', {type:'checkbox'});
ssoEnabled.checked = !!sso.enabled;
const ssoName = el('input', {type:'text', placeholder:'e.g. Okta, Azure AD',
value: sso.idp_name || ''});
// v0.4.6: optional FleetDM-style IdP logo URL. The login screen
// will render this as the brand mark on the "Sign in with X"
// button once the runtime SSO flow ships; for v0.4.6 we just
// persist it.
const ssoLogo = el('input', {type:'text',
placeholder:'https://idp.example.com/logo.svg',
value: sso.idp_logo_url || ''});
const ssoUrl = el('input', {type:'text', placeholder:'https://idp.example.com/metadata',
value: sso.metadata_url || ''});
// The textarea inherits the same chrome via the global
// `label.field textarea` rule, plus the monospace family for
// pasting raw XML. Children come after the attrs object — the
// initial value is the only "child".
const ssoXml = el('textarea',
{rows:'6',
spellcheck:'false', autocapitalize:'off',
placeholder:'<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"…',
style:'font-family:var(--mono);font-size:12px;resize:vertical'},
sso.metadata || '');
// The mode picker is a styled <select> so it aligns with text
// inputs in the same `.form-row` — the global `label.field
// select` rule takes care of the chrome.
const ssoMode = el('select', {}, [
el('option', {value:'url'}, 'Metadata URL'),
el('option', {value:'xml'}, 'Metadata XML'),
]);
ssoMode.value = sso.metadata && !sso.metadata_url ? 'xml' : 'url';
// v0.4.63: the IdP metadata URL now sits inside the 4-col header
// grid as column 4, so the SSO row is column-for-column aligned with
// the Administrator account row above. When the operator switches
// to XML mode, column 4 collapses (display:none) and the multi-line
// XML textarea takes its own full-width row below — there's no way
// to fit a 6-row textarea into a single grid cell without making
// the rest of the row look stretched.
const urlWrap = el('label', {class:'field'}, [
el('span', {class:'name'}, 'IdP metadata URL'),
ssoUrl,
]);
const xmlWrap = el('label', {class:'field', style:'margin-top:14px'}, [
el('span', {class:'name'}, 'IdP metadata XML'),
ssoXml,
el('span', {class:'hint'},
'Paste the raw <EntityDescriptor>…</EntityDescriptor> document from your IdP.'),
]);
// Hint that used to live under the URL field; surfaced once below
// the whole row so it doesn't compete with the in-grid layout.
const urlHint = el('p', {class:'msg', style:'margin-top:10px;margin-bottom:0'},
'OpenPXE will fetch the metadata URL once SSO sign-in lands; v0.4.63 stores it.');
const refreshSsoFields = () => {
if (ssoMode.value === 'url') {
urlWrap.style.display = ''; xmlWrap.style.display = 'none';
urlHint.style.display = '';
} else {
urlWrap.style.display = 'none'; xmlWrap.style.display = '';
urlHint.style.display = 'none';
}
};
ssoMode.onchange = refreshSsoFields;
const ssoMsg = el('div', {class:'msg', style:'margin-top:8px'});
const ssoSave = el('button', {style:'margin-top:16px', onclick: async () => {
ssoMsg.textContent = ''; ssoMsg.className = 'msg';
const payload = {
enabled: ssoEnabled.checked,
idp_name: ssoName.value,
idp_logo_url: ssoLogo.value,
metadata: ssoMode.value === 'xml' ? ssoXml.value : '',
metadata_url: ssoMode.value === 'url' ? ssoUrl.value : '',
};
const r = await putJSON('/api/sso', payload);
if (r.ok) {
ssoMsg.textContent = ssoEnabled.checked
? 'SSO configuration saved. Runtime sign-in flow ships in a future release.'
: 'SSO configuration saved (disabled).';
ssoMsg.className = 'msg ok';
} else {
const t = await r.text();
ssoMsg.textContent = 'Save failed: ' + t;
ssoMsg.className = 'msg err';
}
}}, 'Save SSO settings');
const ssoCard = el('div', {class:'card'}, [
el('header', {}, [
el('h2', {}, 'Single sign-on (SAML)'),
el('span', {class:'sub'},
sso.enabled
? (sso.metadata_url || sso.metadata
? 'configured · runtime pending'
: 'enabled but missing source')
: 'disabled'),
]),
el('div', {class:'body'}, [
el('p', {class:'msg', style:'margin-bottom:14px'},
'Configure your SAML IdP today; OpenPXE persists the metadata so ' +
'when SSO sign-in lights up in a future release, no operator ' +
're-entry is needed. The local administrator account above is ' +
'always available as a fallback owner regardless of SSO state.'),
el('label', {class:'check', style:'margin-bottom:14px;max-width:280px'}, [
ssoEnabled,
el('span', {}, 'Enable single sign-on'),
]),
// v0.4.63: 4-column form-row that matches the Administrator
// account card above column-for-column — display name / logo
// URL / metadata source / metadata URL. All four controls share
// the same `label.field` chrome so they line up cleanly. When
// the operator picks "Metadata XML" the URL column collapses
// and the multi-line textarea drops below the row.
el('div', {class:'form-row'}, [
el('label', {class:'field'}, [
el('span', {class:'name'}, 'IdP display name'),
ssoName,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'IdP logo URL'),
ssoLogo,
]),
el('label', {class:'field'}, [
el('span', {class:'name'}, 'Metadata source'),
ssoMode,
]),
urlWrap,
]),
xmlWrap,
urlHint,
ssoSave, ssoMsg,
]),
]);
// Wire up + paint the initial visibility now that all elements
// referenced by `refreshSsoFields` are attached.
refreshSsoFields();
// ── Custom logo upload.
// Single-file drop-zone; PNG/SVG/JPEG/WebP/GIF up to 2 MB.
// Persisted as <work_dir>/branding/logo.<ext> and served from
@@ -1113,7 +1394,7 @@
'No API documentation returned by /api/docs.')),
]);
return el('div', {class:'grid'}, [accessCard, logoCard, apiCard]);
return el('div', {class:'grid'}, [accountCard, ssoCard, logoCard, apiCard]);
},
about: async () => {
@@ -1252,7 +1533,287 @@
if (a) { e.preventDefault(); render(a.dataset.view); }
});
// ── Auth bootstrap (v0.4.5) ──────────────────────────────────────
// Before painting the dashboard, ask /api/me whether the operator
// needs to bootstrap an admin (`setup_required`), sign in
// (`!authenticated`), or just load the dashboard. The auth screen
// takes over the viewport completely — no half-rendered chrome
// bleeding through. Sonarr/Radarr-style.
let chipsInterval = null;
let authScreenEl = null;
let ssoConfig = null;
function teardownAuthScreen() {
if (authScreenEl && authScreenEl.parentNode) {
authScreenEl.parentNode.removeChild(authScreenEl);
}
authScreenEl = null;
document.querySelector('.shell').style.display = '';
}
function buildLoginCard() {
const usernameInput = el('input', {type:'text', name:'username', autocomplete:'username', autofocus:'autofocus', spellcheck:'false'});
const passwordInput = el('input', {type:'password', name:'password', autocomplete:'current-password'});
const err = el('div', {class:'auth-err', style:'display:none'});
const submit = el('button', {class:'submit', type:'submit'}, 'Sign in');
const ssoButton = ssoConfig && ssoConfig.enabled && (ssoConfig.metadata_url || ssoConfig.metadata)
? el('button', {type:'button', class:'sso-btn', onclick: () => {
// SSO login flow lands in a later release — for now we
// surface a friendly note so the operator knows the config
// landed but the runtime hookup is pending.
err.textContent = 'SSO sign-in is configured but the runtime flow ships in a future release. Sign in with the local admin for now.';
err.style.display = '';
}}, [
el('div', {}, 'Sign in with ' + (ssoConfig.idp_name || 'SSO')),
el('div', {class:'meta'}, 'configured · runtime flow pending'),
])
: null;
const form = el('form', {class:'auth-form', onsubmit: async (e) => {
e.preventDefault();
err.style.display = 'none';
submit.disabled = true;
submit.textContent = 'Signing in…';
try {
const r = await fetch('/api/login', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({username: usernameInput.value, password: passwordInput.value}),
});
if (r.ok) {
passwordInput.value = '';
teardownAuthScreen();
await startDashboard();
return;
}
const j = await r.json().catch(() => ({}));
err.textContent = j.error || ('Sign-in failed: HTTP ' + r.status);
err.style.display = '';
} catch (ex) {
err.textContent = 'Network error: ' + (ex && ex.message ? ex.message : ex);
err.style.display = '';
} finally {
submit.disabled = false;
submit.textContent = 'Sign in';
}
}}, [
el('div', {class:'brand-row'}, [
el('img', {src:'/assets/logo.svg', alt:''}),
el('div', {class:'name'}, 'OpenPXE'),
]),
el('h2', {}, 'Sign in'),
el('p', {class:'lede'}, 'Enter your administrator credentials. Forgot them? SSH to the host and remove work_dir/auth.json — the next launch will re-prompt for setup.'),
el('label', {class:'field'}, [
el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Username'),
usernameInput,
]),
el('label', {class:'field'}, [
el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Password'),
passwordInput,
]),
submit,
ssoButton,
err,
el('div', {class:'auth-foot'}, 'OpenPXE · ' + (window.location.host || '')),
]);
return form;
}
function buildSetupCard() {
const usernameInput = el('input', {type:'text', name:'username', autocomplete:'username', autofocus:'autofocus', spellcheck:'false'});
const passwordInput = el('input', {type:'password', name:'password', autocomplete:'new-password'});
const confirmInput = el('input', {type:'password', name:'confirm', autocomplete:'new-password'});
const err = el('div', {class:'auth-err', style:'display:none'});
const submit = el('button', {class:'submit', type:'submit'}, 'Create administrator');
const form = el('form', {class:'auth-form', onsubmit: async (e) => {
e.preventDefault();
err.style.display = 'none';
if (passwordInput.value !== confirmInput.value) {
err.textContent = 'Passwords do not match.';
err.style.display = '';
return;
}
if (passwordInput.value.length < 8) {
err.textContent = 'Password must be at least 8 characters.';
err.style.display = '';
return;
}
submit.disabled = true;
submit.textContent = 'Creating…';
try {
const r = await fetch('/api/setup', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({username: usernameInput.value, password: passwordInput.value}),
});
if (r.ok) {
passwordInput.value = '';
confirmInput.value = '';
teardownAuthScreen();
await startDashboard();
return;
}
const j = await r.json().catch(() => ({}));
err.textContent = j.error || ('Setup failed: HTTP ' + r.status);
err.style.display = '';
} catch (ex) {
err.textContent = 'Network error: ' + (ex && ex.message ? ex.message : ex);
err.style.display = '';
} finally {
submit.disabled = false;
submit.textContent = 'Create administrator';
}
}}, [
el('div', {class:'brand-row'}, [
el('img', {src:'/assets/logo.svg', alt:''}),
el('div', {class:'name'}, 'OpenPXE'),
]),
el('h2', {}, 'First-run setup'),
el('p', {class:'lede'}, 'Welcome. Create the administrator account that will own this OpenPXE deployment. Additional users come in through SSO later.'),
el('label', {class:'field'}, [
el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Username'),
usernameInput,
]),
el('label', {class:'field'}, [
el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Password (≥8 chars)'),
passwordInput,
]),
el('label', {class:'field'}, [
el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Confirm password'),
confirmInput,
]),
submit,
err,
el('div', {class:'auth-foot'}, 'OpenPXE · ' + (window.location.host || '')),
]);
return form;
}
function showAuthScreen(mode) {
// Tear down any previous screen + the dashboard chrome.
if (authScreenEl && authScreenEl.parentNode) {
authScreenEl.parentNode.removeChild(authScreenEl);
}
const shell = document.querySelector('.shell');
if (shell) shell.style.display = 'none';
if (chipsInterval) { clearInterval(chipsInterval); chipsInterval = null; }
const card = (mode === 'setup' ? buildSetupCard() : buildLoginCard());
authScreenEl = el('div', {class:'auth-screen'},
el('div', {class:'auth-card'}, card));
document.body.appendChild(authScreenEl);
// Focus the first visible input (autofocus on dynamically created
// inputs doesn't fire in all browsers).
setTimeout(() => {
const inp = authScreenEl.querySelector('input[type="text"], input[type="password"]');
if (inp) inp.focus();
}, 30);
}
async function startDashboard() {
// v0.4.6: light up the top-right user-menu chip. The button is
// hidden in index.html until /api/me confirms a signed-in session,
// so we don't show the icon (then hide it) when the user lands
// on /login. Clicking the icon opens a small popover with
// Name / Edit account / Sign out.
try {
const me = await fetch('/api/me').then(r => r.ok ? r.json() : null);
const wrap = $('[data-bind=user_menu_wrap]');
const pop = $('[data-bind=user_menu_pop]');
const name = $('[data-bind=user_pop_name]');
const edit = $('[data-bind=user_pop_edit]');
const out = $('[data-bind=user_pop_logout]');
const btn = $('#user-menu-btn');
if (wrap && me && me.authenticated && me.user) {
wrap.style.display = '';
if (name) name.textContent = me.user.username;
if (btn) btn.title = 'Signed in as ' + me.user.username;
if (btn && !btn._wired) {
btn._wired = true;
btn.addEventListener('click', (e) => {
e.stopPropagation();
const open = !pop.hidden;
pop.hidden = open;
btn.setAttribute('aria-expanded', String(!open));
});
}
if (edit && !edit._wired) {
edit._wired = true;
edit.addEventListener('click', () => {
pop.hidden = true;
btn.setAttribute('aria-expanded', 'false');
render('settings');
});
}
if (out && !out._wired) {
out._wired = true;
out.addEventListener('click', async () => {
pop.hidden = true;
btn.setAttribute('aria-expanded', 'false');
await fetch('/api/logout', {method:'POST'}).catch(() => {});
wrap.style.display = 'none';
showAuthScreen('login');
});
}
// Click-outside-to-close, wired once. Stored on document so we
// don't re-attach every render.
if (!document._userPopWired) {
document._userPopWired = true;
document.addEventListener('click', (e) => {
if (pop.hidden) return;
if (e.target.closest('.user-menu')) return;
pop.hidden = true;
btn.setAttribute('aria-expanded', 'false');
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !pop.hidden) {
pop.hidden = true;
btn.setAttribute('aria-expanded', 'false');
}
});
}
}
} catch (e) { /* surfaces elsewhere */ }
render('dashboard');
refreshChips();
setInterval(refreshChips, 3000);
await refreshChips();
if (!chipsInterval) chipsInterval = setInterval(refreshChips, 3000);
}
async function bootstrap() {
let me;
try {
me = await fetch('/api/me').then(r => r.json());
} catch (e) {
// /api/me is unauthenticated in every state — if we can't reach
// it the server is genuinely down, not an auth problem.
document.body.appendChild(el('div', {class:'auth-screen'},
el('div', {class:'auth-card'}, [
el('div', {class:'brand-row'}, [
el('img', {src:'/assets/logo.svg', alt:''}),
el('div', {class:'name'}, 'OpenPXE'),
]),
el('h2', {}, 'Connection error'),
el('p', {class:'lede'}, 'Could not reach the OpenPXE server. Refresh once it is back up.'),
])));
return;
}
// Preload the SSO config so the login card can offer the operator
// an "Sign in with X" button when configured. Failure is harmless.
try { ssoConfig = await fetch('/api/sso').then(r => r.ok ? r.json() : null); }
catch { ssoConfig = null; }
if (me.setup_required) {
showAuthScreen('setup');
return;
}
if (!me.authenticated) {
showAuthScreen('login');
return;
}
await startDashboard();
}
bootstrap();
})();
+33 -5
View File
@@ -5,8 +5,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<title>OpenPXE</title>
<link rel="stylesheet" href="/assets/app.css" />
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg" />
<!-- v0.4.61: the `?v=…` query string is replaced by the server at
request time with the running OpenPXE version. That guarantees a
fresh URL on every upgrade so browsers (and intermediary proxies)
can't keep serving stale JS / CSS / branding from before the
deploy. Combined with `Cache-Control: no-cache, must-revalidate`
on the asset handlers, the practical caching window is one
version. -->
<link rel="stylesheet" href="/assets/app.css?v={{ASSET_VERSION}}" />
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg?v={{ASSET_VERSION}}" />
<!-- Theme is read from localStorage *before* paint to avoid the
dark→light flash on every navigation. Falls back to the OS
preference and finally to dark. -->
@@ -26,7 +33,7 @@
<div class="shell">
<aside class="sidebar">
<div class="brand">
<img src="/assets/logo.svg" alt="OpenPXE" />
<img src="/assets/logo.svg?v={{ASSET_VERSION}}" alt="OpenPXE" />
<strong>OpenPXE</strong>
</div>
<nav>
@@ -59,7 +66,7 @@
<!-- The brand badge at the top can be overridden by operator-uploaded
logos; keep "OpenPXE v…" pinned in the footer so the backend
identity is always visible regardless of branding. -->
<div class="footer-version">OpenPXE&nbsp;v<span data-bind="version">0.4.4</span></div>
<div class="footer-version">OpenPXE&nbsp;v<span data-bind="version">0.4.63</span></div>
</div>
</aside>
@@ -89,11 +96,32 @@
<path d="M20.5 14A8 8 0 0 1 10 3.5 a8 8 0 1 0 10.5 10.5z"/>
</svg>
</button>
<!-- v0.4.6: signed-in operator menu. Sits next to the theme toggle
in the top-right corner so the sidebar footer stays clean for
the "Service status / Advertised URL / Backend version" trio.
The whole block is hidden until /api/me confirms a session. -->
<div class="user-menu" data-bind="user_menu_wrap" style="display:none">
<button id="user-menu-btn" class="user-btn" type="button"
aria-label="Account menu" aria-haspopup="true" aria-expanded="false"
title="Account">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="8" r="3.6"/>
<path d="M4.5 20a7.5 7.5 0 0 1 15 0"/>
</svg>
</button>
<div id="user-menu-pop" class="user-pop" data-bind="user_menu_pop" hidden>
<div class="user-pop-name" data-bind="user_pop_name"></div>
<button type="button" class="user-pop-item" data-bind="user_pop_edit">Edit account</button>
<button type="button" class="user-pop-item user-pop-danger" data-bind="user_pop_logout">Sign out</button>
</div>
</div>
</header>
<main class="main" id="view-root"></main>
</div>
<script src="/assets/app.js"></script>
<script src="/assets/app.js?v={{ASSET_VERSION}}"></script>
</body>
</html>
+14 -4
View File
@@ -7,11 +7,21 @@
//! nav, top bar with secondary tabs, card-dense content panels.
#![forbid(unsafe_code)]
/// Render the top-level page. `base_url` is interpolated into the footer
/// so operators can see at a glance what URL clients are PXE-booting from.
/// Render the top-level page.
///
/// * `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]
pub fn index_html(base_url: &str) -> String {
INDEX_HTML.replace("{{BASE_URL}}", base_url)
pub fn index_html(base_url: &str, asset_version: &str) -> String {
INDEX_HTML
.replace("{{BASE_URL}}", base_url)
.replace("{{ASSET_VERSION}}", asset_version)
}
#[must_use]
+50 -20
View File
@@ -16,7 +16,15 @@
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
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
@@ -28,6 +36,22 @@ RUN mkdir -p assets/ipxe && bash scripts/fetch-ipxe.sh
FROM rust:${RUST_VERSION}-bookworm AS build
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
# 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
@@ -42,14 +66,14 @@ COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/
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
# networked FS; this forces a fingerprint check.
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/src/target,sharing=locked \
find crates -name '*.rs' -exec touch {} + && \
cargo build --release --bin openpxe && \
cp target/release/openpxe /openpxe && \
cargo build --release --target x86_64-unknown-linux-musl --bin openpxe && \
cp target/x86_64-unknown-linux-musl/release/openpxe /openpxe && \
ls -l /openpxe
########## runtime ##########
@@ -62,22 +86,28 @@ RUN apt-get update \
&& useradd --system --uid 10001 --home-dir /var/lib/openpxe --shell /usr/sbin/nologin openpxe \
&& mkdir -p /var/lib/openpxe/isos /var/lib/openpxe/work /var/lib/openpxe/smb \
&& chown -R openpxe:openpxe /var/lib/openpxe
# Runtime deps explained:
# wimtools - provides `wimlib-imagex`, used to inject startnet.cmd into boot.wim.
# samba - `smbd` serves extracted Windows install media on :445 for WinPE
# to `net use`. Guest read-only, scoped to /var/lib/openpxe/smb.
# nfs-common - provides `mount.nfs` / `mount.nfs4` for the Storage tab's
# NFS share manager. Mount also requires the container to run
# with CAP_SYS_ADMIN — without it, mount(2) returns EPERM and
# the manager surfaces a clear error in the UI instead of
# failing silently.
# iproute2 - `ip addr` / `ip route` for the auto-detected Network tab
# fields (NIC name, subnet mask, default gateway). Tiny,
# always available; we don't pull in netlink crates for
# this one-shot startup probe.
# gosu - drops privileges cleanly from root after the entrypoint fixes
# bind-mount ownership (common OpenShift/Docker UX issue).
# Windows-specific tools only activate when the WebUI toggle is on.
# v0.4.5: the openpxe binary itself is now built against musl and is
# fully static — no glibc dependency. The runtime stage still ships
# Debian slim because OpenPXE shells out to the four packages below for
# functionality we deliberately don't reimplement in-process:
# wimtools - `wimlib-imagex`, used to inject startnet.cmd into boot.wim.
# samba - `smbd` serves extracted Windows install media on :445 so
# WinPE can `net use`. Guest read-only, scoped to
# /var/lib/openpxe/smb.
# nfs-common - `mount.nfs` / `mount.nfs4` for the Storage tab's NFS
# share manager. Mount requires CAP_SYS_ADMIN; without it
# mount(2) returns EPERM and the manager surfaces a clear
# error in the UI.
# iproute2 - `ip addr` / `ip route` for the auto-detected Network
# tab fields (NIC name, subnet mask, default gateway).
# Tiny, always available; we don't pull in netlink crates
# for this one-shot startup probe.
# gosu - drops privileges cleanly from root after the entrypoint
# fixes bind-mount ownership (common OpenShift/Docker UX
# issue).
# 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 deploy/docker/entrypoint.sh /usr/local/bin/entrypoint.sh
+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